avatar

Andres Jaimes

Parsing Json with the Play Framework

By Andres Jaimes

- 9 minutes read - 1734 words

This article discusses different common scenarios for JSON parsing and conversion, useful when working with the Play Framework library. All the examples in the article, use one or more of the following library imports:

import play.api.libs.functional.syntax._
import play.api.libs.json.{Format, JsError, JsNull, JsPath, JsResult, JsString, JsSuccess, JsValue, Json, OFormat, Reads}

The first import is required to use the special and and or functions found through the examples below.

JSON that matches property names

The first snippet allows us to parse different basic data types from a json-formatted input. The following json:

{
  "string": "some value",
  "long": 1,
  "double": 2.0,
  "boolean": true,
  "date": "2022-01-03T16:21:23.377Z",
  "strings": ["one", "two", "three"],
  "longs": [1, 2, 3]
}

can be parsed by the following case class and its companion object:

case class MyClass (
  string: String,
  long: Long,
  double: Double,
  boolean: Boolean,
  date: Instant,
  strings: List[String],
  longs: List[Long]
)

object MyClass {
  implicit val format: OFormat[MyClass] = Json.format[MyClass]
}

The implicit format value in the MyClass object deals with the magic behind the scenes. It automatically parses from and converts to json values.

This is all that is required for basic conversion between json and case classes. The sections below keep adding features and exploring special parsing and conversion cases.

JSON with optional fields

Sometimes json values are not present. We use Scala’s Option to represent these values in case classes. See the following example:

case class MyClass (
  string: Option[String],
  number: Option[Long]
)

object MyClass {
  implicit val format: OFormat[MyClass] = Json.format[MyClass]
}

The Option fields allow us to parse a json input with a missing numeric field:

{
  "string": "some value",
}

or a missing string field:

{
  "number": 1,
}

or an input that contains both values:

{
  "string": "some value",
  "number": 1
}

even an empty json object is valid, because all the fields declared in our case class are optional:

{ }

The scala fields will be set to None for those scenarios where the field is missing from the json source. A similar pattern can be used for reading optional lists. For example:

final case class MyClass (
  strings: Option[List[String]],
  numbers: Option[List[Long]]
)

object MyClass {
  implicit val format: OFormat[MyClass] = Json.format[MyClass]
}

Separating format into reader and writer

We used OFormat in the previous examples for reading and writing json values. There are times when we need to separate the reading and the writing parts. We can use the Reads and Writes to do it like this:

case class MyClass(
  string: String,
  number: Long
)

object MyClass {

  implicit val reads: Reads[MyClass] = Json.reads[MyClass]
  implicit val writes: Writes[MyClass] = Json.writes[MyClass]

}

It is not necessary to have both a reader and a writer. We can only define the one that we need. For example, if we are reading json, but not writing it, then a reader will be enough.

This last example accomplishes the same as OFormat and it seems that at this point we have not gained anything, but its real value will be seen in the following sections.

Custom Reads

A custom read can be used when json’s field names do not match the ones we have defined for our case class.

case class User (
  id: String,
  name: String,
  colors: List[String],
  flavors: Option[List[String]]
)

object User {
  implicit val reads: Reads[User] = (
    (JsPath \ "user_id").read[String] and
    (JsPath \ "user_name").read[String] and
    (JsPath \ "favorite_colors").read[List[String]] and
    (JsPath \ "favorite_flavors").readNullable[List[String]]
  ) (User.apply _)
}

The reads value has a series of read and readNullable calls that follow the field order defined by the case class. This does not mean that the json file has to match this specific order, it only means we have to follow the case class order when we place the series of read's and readNullable's in our code.

read is used for non-optional values and readNullable for optional ones.

Note how each of the read/readNullable calls specifies the type to be read (String, List[String], etc.)

Custom Writes

Similar to custom reads, there may be times when we need to generate json that writes to a specific format.

case class Customer (
  id: String,
  name: String,
  colors: Option[List[String]],
  recurrent: Option[Boolean]
)

object Customer {
    
  implicit val writes: Writes[Customer] = (
    (JsPath \ "customer_id").write[String] and
      (JsPath \ "customer_name").write[String] and
      (JsPath \ "favorite_colors").writeNullable[List[String]] and
      (JsPath \ "recurrent_customer").writeNullable[Boolean]
    )(unlift(Customer.unapply))

}

The writes value has a series of write and writeNullable calls that follow the field order defined by the case class.

write is used for non-optional values while writeNullable for optional ones.

Note how the write and writeNullable calls declare the type to be written (String, List[String], Boolean, etc.)

Special case: parsing to case classes with one property

The following pattern must be followed when we have a case class with a single property:

case class User (
  name: Option[String]
)

object User {
  
  implicit val read: Reads[User] =
    (JsPath \ "some_name_field").readNullable[String]
      .map { s => User(s) }
    
}

Note that we are using map instead of apply. The following can be used an alternative implementation:

object User {

  implicit val reads: Reads[User] =
    (JsPath \ "some_name_field").readNullable[String].map(User.apply)

}

Play’s json library takes care of this required change when we are not using a custom OFormat/Reads/Writes, as we can see in the following example:

case class User (
  name: String
)


object User {

  implicit val reads: Reads[User] = Json.reads[User]
  implicit val writes: Writes[User] = Json.writes[User]
  // or just:
  // implicit val format: OFormat[User] = Json.format[User]

}

Fields that can return different data types

There are times when a json field can have multiple data types. The following example shows how to deal with a field that sometimes is received as a String and some others as a List[String].

In our example, the field audience can be received as a single string or as a list, so in our case class we have decided to declare it as a List[String]. This is useful because we can represent a single string as a list with one item.

case class Presentation (
  subject: String,
  audience: List[String]
)

object Presentation {

  val stringAsListOfString: Reads[List[String]] =
    implicitly[Reads[String]].map(List(_))

  implicit val reads: Reads[Presentation] = (
    (JsPath \ "sub").read[String] and
    ((JsPath \ "aud").read[List[String]] or (JsPath \ "aud").read[List[String]](readStringAsListOfString))
  ) (Presentation.apply _)

}

stringAsListOfString is a helper function that reads a single string and maps it to a list with one item.

The reads value tries to parse aud as a List[String]. If it fails, the code after or will be used. This is where we call our stringAsListOfString function. Do not miss the right use of parenthesis when using or.

Note the way we call stringAsListOfString. We will use that same syntax in the following section.

Field validation

Field validation uses a syntax similar to the one found in the previous section. Play’s JSON library includes some predefined validators, but we can create our own if we need some specific functionality.

case class MyClass (
  l: Long,
  d: Double,
  email: String,
  custom1: String,
  custom2: String
)

object MyClass {
  implicit val reads: Reads[MyClass] = (
    (JsPath \ "l").read[Long](Reads.min(10) or Reads.max(20)) and
    (JsPath \ "d").read[Double](Reads.min(0.1) or Reads.max(1.0)) and
    (JsPath \ "email").read[String](Reads.email) and
    (JsPath \ "custom1").read[String](Reads.pattern("""^[a-zA-Z0-9]{5,10}]$""".r, "Some generic error"))
    (JsPath \ "custom2").read[String](Reads.pattern("""^[a-zA-Z0-9]{5,10}]$""".r, "error.custom"))
  ) (MyClass.apply _)
}

Remember that we can use readNullable instead of read when we have optional fields.

custom1 and custom2 fields use regular expressions for validation. The second parameter is the error message to use if the validation fails. Note that for custom1 we are using a generic string as error message, and for custom2 we are leveraging on Play’s internationalization functionality to display the appropriate message.

Field transformation

Where are times when Play needs some help to convert between types. A json input may contain “TRUE”, “true” or “True” as valid Boolean values. We have to create a function that reads the value and converts it to our target data type.


case class Presentation (
  subject: String,
  hidden: Boolean
)

object Presentation {

  val stringAsBoolean: Reads[Boolean] =
    implicitly[Reads[String]].map(_.toLowerCase == "true")

  implicit val reads: Reads[Presentation] = (
    (JsPath \ "sub").read[String] and
    (JsPath \ "is_hidden").read[Boolean](stringAsBoolean)
  ) (Presentation.apply _)

}

We are adding a function called stringAsBoolean which reads a string and then maps the read value to the lower-case “true” string.

Converter functions (also called Formatters) can live in external objects as well. In the following example we read a string and convert it to a URI that matches the required data type in our case class.

Important: In the following example we are using the Format trait instead of the OFormat one.

OFormat does not work for this implementation, however Format may work for any of the previous examples found on this page.

object JsonFormatters {

  import java.net.URI
  
  implicit val uriFormat: Format[URI] = new Format[URI] {
    def writes(s: URI): JsValue = JsString(s.toString)

    def reads(json: JsValue): JsResult[URI] = json match {
      case JsNull => JsError()
      case _ => JsSuccess(URI.create(json.as[String]))
    }
  }

}

As in previous examples, writes is only required if we want to be able to generate json from our case classes.

case class Link (
  id: String,
  url: URI,
)

object Link {
  implicit val uriFormat: Format[URI] = JsonFormatters.uriFormat
  implicit val format = Json.format[Link]
}

We can mix everything we have learned before and create a custom reader as well:

object Link {
  implicit val reads: Reads[Link] = (
    (JsPath \ "link_id").read[String] and
      (JsPath \ "link_url").read[URI](JsonFormatters.uriFormat)
    ) (Link.apply _)
}

Splitting case classes

Some times it is necessary to split a json input parsing into multiple sections. This is required when we are doing parsing of more than 22 fields – remember case classes cannot have more than 22 fields – or for code organization purposes.

case class Claims (
  registered: Registered,
  public: List[Public]
)

object Claims {
  
  implicit val reads: Reads[Claims] = (
    JsPath.read[Registered] and
    (JsPath \ "data").read[List[Public]]
  ) (Claims.apply _)

}

case class Registered (
  subject: String,
  audience: List[String]
)

object Registered {
  implicit val reads: Reads[Registered] = (
    (JsPath \ "sub").read[String] and
    (JsPath \ "aud").read[List[String]])
  ) (Registered.apply _)
}

case class Public (
  name: String,
  tags: List[String]
)

object Public {
  implicit val reads: Reads[Public] = (
    (JsPath \ "name").read[String] and
    (JsPath \ "tags").readNullable[List[String]])
  ) (Public.apply _)
  // or simply
  // implicit val reads: Reads[Public] = Json.reads[Public]
}

The previous code should be able to parse the following json input:

{
  "sub": "some string",
  "aud": ["some audience 1", "some audience 2"],
  "data": [
    {
      "name": "some string 1",
      "tags": ["tag 1", "tag 2"] 
    },
    {
      "name": "some string 2",
      "tags": ["tag 3", "tag 4"] 
    }
  ]
}