§JSON Reads/Writes/Format Combinators
JSON basics introduced Reads
and Writes
converters which are used to convert between JsValue
structures and other data types. This page covers in greater detail how to build these converters and how to use validation during conversion.
The examples on this page will use this JsValue
structure and corresponding model:
import play.api.libs.json._
val json: JsValue = Json.parse("""
{
"name" : "Watership Down",
"location" : {
"lat" : 51.235685,
"long" : -1.309197
},
"residents" : [ {
"name" : "Fiver",
"age" : 4,
"role" : null
}, {
"name" : "Bigwig",
"age" : 6,
"role" : "Owsla"
} ]
}
""")
case class Location(lat: Double, long: Double)
case class Resident(name: String, age: Int, role: Option[String])
case class Place(name: String, location: Location, residents: Seq[Resident])
§JsPath
JsPath
is a core building block for creating Reads
/Writes
. JsPath
represents the location of data in a JsValue
structure. You can use the JsPath
object (root path) to define a JsPath
child instance by using syntax similar to traversing JsValue
:
import play.api.libs.json._
val json = { ... }
// Simple path
val latPath = JsPath \ "location" \ "lat"
// Recursive path
val namesPath = JsPath \\ "name"
// Indexed path
val firstResidentPath = (JsPath \ "residents")(0)
The play.api.libs.json
package defines an alias for JsPath
: __
(double underscore). You can use this if you prefer:
val longPath = __ \ "location" \ "long"
§Reads
Reads
converters are used to convert from a JsValue
to another type. You can combine and nest Reads
to create more complex Reads
.
You will require these imports to create Reads
:
import play.api.libs.json._ // JSON library
import play.api.libs.json.Reads._ // Custom validation helpers
§Path Reads
JsPath
has methods to create special Reads
that apply another Reads
to a JsValue
at a specified path:
JsPath.read[T](implicit r: Reads[T]): Reads[T]
- Creates aReads[T]
that will apply the implicit argumentr
to theJsValue
at this path.JsPath.readNullable[T](implicit r: Reads[T]): Reads[Option[T]]
- Use for paths that may be missing or can contain a null value.
Note: The JSON library provides implicit
Reads
for basic types such asString
,Int
,Double
, etc.
Defining an individual path Reads
looks like this:
val nameReads: Reads[String] = (JsPath \ "name").read[String]
§Complex Reads
You can combine individual path Reads
, using play.api.libs.functional.syntax
, to form more complex Reads
which can be used to convert to complex models.
For easier understanding, we’ll break down the combine functionality into two statements. First combine Reads
objects using the and
combinator:
import play.api.libs.functional.syntax._ // Combinator syntax
val locationReadsBuilder =
(JsPath \ "lat").read[Double] and
(JsPath \ "long").read[Double]
This will yield a type of FunctionalBuilder[Reads]#CanBuild2[Double, Double]
. This is an intermediary object and you don’t need to worry too much about it, just know that it’s used to create a complex Reads
.
Second call the apply
method of CanBuildX
with a function to translate individual values to your model, this will return your complex Reads
. If you have a case class with a matching constructor signature, you can just use its apply
method:
implicit val locationReads: Reads[Location] = locationReadsBuilder.apply(Location.apply _)
Here’s the same code in a single statement:
implicit val locationReads: Reads[Location] = (
(JsPath \ "lat").read[Double] and
(JsPath \ "long").read[Double]
)(Location.apply _)
§Functional combinators with Reads
Usual functional combinators are available, to transform and transform Reads
instances or their results.
map
- Map successful value.flatMap
- Transform previous result into another successful or erroneous result.collect
- Filter (using pattern matching) and map successful value.orElse
- Specify an alternativeReads
for heterogenous JSON value.andThen
- Specify anotherReads
that post-process the result of a first one.
val strReads: Reads[String] = JsPath.read[String]
// .map
val intReads: Reads[Int] = strReads.map { str =>
str.toInt
}
// e.g. reads JsString("123") as 123
// .flatMap
val objReads: Reads[JsObject] = strReads.flatMap { rawJson =>
// consider something like { "foo": "{ \"stringified\": \"json\" }" }
Reads { _ =>
Json.parse(rawJson).validate[JsObject]
}
}
// .collect
val boolReads1: Reads[Boolean] = strReads.collect(JsonValidationError("in.case.it.doesn-t.match")) {
case "no" | "false" | "n" => false
case _ => true
}
// .orElse
val boolReads2: Reads[Boolean] = JsPath.read[Boolean].orElse(boolReads1)
// .andThen
val postprocessing: Reads[Boolean] = Reads[JsBoolean] {
case JsString("no" | "false" | "n") =>
JsSuccess(JsFalse)
case _ => JsSuccess(JsTrue)
}.andThen(JsPath.read[Boolean])
The filter combinators can also be applied on Reads
(see the next section for more validation).
val positiveIntReads = JsPath.read[Int].filter(_ > 0)
val smallIntReads = positiveIntReads.filterNot(_ > 100)
val positiveIntReadsWithCustomErr = JsPath
.read[Int]
.filter(JsonValidationError("error.positive-int.expected"))(_ > 0)
Some specific combinators are available to process JSON before reading (contrary to .andThen
combinator).
// .composeWith
val preprocessing1: Reads[Boolean] =
JsPath
.read[Boolean]
.composeWith(Reads[JsBoolean] {
case JsString("no" | "false" | "n") =>
JsSuccess(JsFalse)
case _ => JsSuccess(JsTrue)
})
val preprocessing2: Reads[Boolean] = JsPath.read[Boolean].preprocess {
case JsString("no" | "false" | "n") =>
JsFalse
case _ => JsTrue
}
§Validation with Reads
The JsValue.validate
method was introduced in JSON basics as the preferred way to perform validation and conversion from a JsValue
to another type. Here’s the basic pattern:
val json = { ... }
val nameReads: Reads[String] = (JsPath \ "name").read[String]
val nameResult: JsResult[String] = json.validate[String](nameReads)
nameResult match {
case JsSuccess(nme, _) => println(s"Name: $nme")
case e: JsError => println(s"Errors: ${JsError.toJson(e)}")
}
Default validation for Reads
is minimal, such as checking for type conversion errors. You can define custom validation rules by using Reads
validation helpers. Here are some that are commonly used:
Reads.email
- Validates a String has email format.Reads.minLength(nb)
- Validates the minimum length of a collection or String.Reads.min
- Validates a minimum value.Reads.max
- Validates a maximum value.Reads[A] keepAnd Reads[B] => Reads[A]
- Operator that triesReads[A]
andReads[B]
but only keeps the result ofReads[A]
(For those who know Scala parser combinatorskeepAnd == <~
).Reads[A] andKeep Reads[B] => Reads[B]
- Operator that triesReads[A]
andReads[B]
but only keeps the result ofReads[B]
(For those who know Scala parser combinatorsandKeep == ~>
).Reads[A] or Reads[B] => Reads
- Operator that performs a logical OR and keeps the result of the lastReads
checked.
To add validation, apply helpers as arguments to the JsPath.read
method:
val improvedNameReads =
(JsPath \ "name").read[String](minLength[String](2))
§Putting it all together
By using complex Reads
and custom validation we can define a set of effective Reads
for our example model and apply them:
import play.api.libs.json._
import play.api.libs.json.Reads._
import play.api.libs.functional.syntax._
implicit val locationReads: Reads[Location] = (
(JsPath \ "lat").read[Double](min(-90.0).keepAnd(max(90.0))) and
(JsPath \ "long").read[Double](min(-180.0).keepAnd(max(180.0)))
)(Location.apply _)
implicit val residentReads: Reads[Resident] = (
(JsPath \ "name").read[String](minLength[String](2)) and
(JsPath \ "age").read[Int](min(0).keepAnd(max(150))) and
(JsPath \ "role").readNullable[String]
)(Resident.apply _)
implicit val placeReads: Reads[Place] = (
(JsPath \ "name").read[String](minLength[String](2)) and
(JsPath \ "location").read[Location] and
(JsPath \ "residents").read[Seq[Resident]]
)(Place.apply _)
val json = { ... }
json.validate[Place] match {
case JsSuccess(place, _) => {
val _: Place = place
// do something with place
}
case e: JsError => {
// error handling flow
}
}
Note that complex Reads
can be nested. In this case, placeReads
uses the previously defined implicit locationReads
and residentReads
at specific paths in the structure.
§Writes
Writes
converters are used to convert from some type to a JsValue
.
You can build complex Writes
using JsPath
and combinators very similar to Reads
. Here’s the Writes
for our example model:
import play.api.libs.json._
import play.api.libs.functional.syntax._
implicit val locationWrites: Writes[Location] = (
(JsPath \ "lat").write[Double] and
(JsPath \ "long").write[Double]
)(l => (l.lat, l.long))
implicit val residentWrites: Writes[Resident] = (
(JsPath \ "name").write[String] and
(JsPath \ "age").write[Int] and
(JsPath \ "role").writeNullable[String]
)(r => (r.name, r.age, r.role))
implicit val placeWrites: Writes[Place] = (
(JsPath \ "name").write[String] and
(JsPath \ "location").write[Location] and
(JsPath \ "residents").write[Seq[Resident]]
)(p => (p.name, p.location, p.residents))
val place = Place(
"Watership Down",
Location(51.235685, -1.309197),
Seq(
Resident("Fiver", 4, None),
Resident("Bigwig", 6, Some("Owsla"))
)
)
val json = Json.toJson(place)
There are a few differences between complex Writes
and Reads
:
- The individual path
Writes
are created using theJsPath.write
method. - There is no validation on conversion to
JsValue
which makes the structure simpler and you won’t need any validation helpers. - The intermediary
FunctionalBuilder#CanBuildX
(created byand
combinators) takes a function that translates a complex typeT
to a tuple matching the individual pathWrites
. Although this is symmetrical to theReads
case, theunapply
method of a case class returns anOption
of a tuple of properties and must be used withunlift
to extract the tuple.
§Functional combinators with Writes
As for Reads
, some functional combinators can be used on Writes
instances, to adapt how to write values as JSON.
contramap
- Apply a transformation on input values before it’s passed to aWrites
.transform
- Apply a transformation on JSON written by a firstWrites
.narrow
- Restrict the type of values that can be written as JSON.
val plus10Writes: Writes[Int] = implicitly[Writes[Int]].contramap(_ + 10)
val doubleAsObj: Writes[Double] =
implicitly[Writes[Double]].transform { js =>
Json.obj("_double" -> js)
}
val someWrites: Writes[Some[String]] =
implicitly[Writes[Option[String]]].narrow[Some[String]]
§Recursive Types
One special case that our example model doesn’t demonstrate is how to handle Reads
and Writes
for recursive types. JsPath
provides lazyRead
and lazyWrite
methods that take call-by-name parameters to handle this:
case class User(name: String, friends: Seq[User])
implicit lazy val userReads: Reads[User] = (
(__ \ "name").read[String] and
(__ \ "friends").lazyRead(Reads.seq[User](userReads))
)(User.apply _)
implicit lazy val userWrites: Writes[User] = (
(__ \ "name").write[String] and
(__ \ "friends").lazyWrite(Writes.seq[User](userWrites))
)(u => (u.name, u.friends))
§Format
Format[T]
is just a mix of the Reads
and Writes
traits and can be used for implicit conversion in place of its components.
§Creating Format from Reads and Writes
You can define a Format
by constructing it from Reads
and Writes
of the same type:
val locationReads: Reads[Location] = (
(JsPath \ "lat").read[Double](min(-90.0).keepAnd(max(90.0))) and
(JsPath \ "long").read[Double](min(-180.0).keepAnd(max(180.0)))
)(Location.apply _)
val locationWrites: Writes[Location] = (
(JsPath \ "lat").write[Double] and
(JsPath \ "long").write[Double]
)(l => (l.lat, l.long))
implicit val locationFormat: Format[Location] =
Format(locationReads, locationWrites)
§Creating Format using combinators
In the case where your Reads
and Writes
are symmetrical (which may not be the case in real applications), you can define a Format
directly from combinators:
implicit val locationFormat: Format[Location] = (
(JsPath \ "lat").format[Double](min(-90.0).keepAnd(max(90.0))) and
(JsPath \ "long").format[Double](min(-180.0).keepAnd(max(180.0)))
)(Location.apply, l => (l.lat, l.long))
Like Reads
and Writes
functional combinators are provided on Format
.
val strFormat = implicitly[Format[String]]
val intFormat: Format[Int] =
strFormat.bimap(_.size, List.fill(_: Int)('?').mkString)
Next: JSON automated mapping