§JSON with HTTP
Play supports HTTP requests and responses with a content type of JSON by using the HTTP API in combination with the JSON library.
See HTTP Programming for details on Controllers, Actions, and routing.
We’ll demonstrate the necessary concepts by designing a simple RESTful web service to GET a list of entities and accept POSTs to create new entities. The service will use a content type of JSON for all data.
Here’s the model we’ll use for our service:
case class Location(lat: Double, long: Double)
object Location {
def unapply(l: Location): Option[(Double, Double)] = Some(l.lat, l.long)
}
case class Place(name: String, location: Location)
object Place {
var list: List[Place] = {
List(
Place(
"Sandleford",
Location(51.377797, -1.318965)
),
Place(
"Watership Down",
Location(51.235685, -1.309197)
)
)
}
def save(place: Place): Unit = {
list = list ::: List(place)
}
def unapply(p: Place): Option[(String, Location)] = Some(p.name, p.location)
}
§Serving a list of entities in JSON
We’ll start by adding the necessary imports to our controller.
import play.api.mvc._
class HomeController @Inject() (cc: ControllerComponents) extends AbstractController(cc) {}
Before we write our Action
, we’ll need the plumbing for doing conversion from our model to a JsValue
representation. This is accomplished by defining an implicit Writes[Place]
.
implicit val locationWrites: Writes[Location] =
(JsPath \ "lat").write[Double].and((JsPath \ "long").write[Double])(unlift(Location.unapply))
implicit val placeWrites: Writes[Place] =
(JsPath \ "name").write[String].and((JsPath \ "location").write[Location])(unlift(Place.unapply))
Next we write our Action
:
def listPlaces() = Action {
val json = Json.toJson(Place.list)
Ok(json)
}
The Action
retrieves a list of Place
objects, converts them to a JsValue
using Json.toJson
with our implicit Writes[Place]
, and returns this as the body of the result. Play will recognize the result as JSON and set the appropriate Content-Type
header and body value for the response.
The last step is to add a route for our Action
in conf/routes
:
GET /places controllers.Application.listPlaces
We can test the action by making a request with a browser or HTTP tool. This example uses the unix command line tool cURL.
curl --include http://localhost:9000/places
Response:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 141
[{"name":"Sandleford","location":{"lat":51.377797,"long":-1.318965}},{"name":"Watership Down","location":{"lat":51.235685,"long":-1.309197}}]
§Creating a new entity instance in JSON
For this Action
we’ll need to define an implicit Reads[Place]
to convert a JsValue
to our model.
implicit val locationReads: Reads[Location] =
(JsPath \ "lat").read[Double].and((JsPath \ "long").read[Double])(Location.apply _)
implicit val placeReads: Reads[Place] =
(JsPath \ "name").read[String].and((JsPath \ "location").read[Location])(Place.apply _)
Next we’ll define the Action
.
def savePlace(): Action[JsValue] = Action(parse.json) { request =>
val placeResult = request.body.validate[Place]
placeResult.fold(
errors => {
BadRequest(Json.obj("message" -> JsError.toJson(errors)))
},
place => {
Place.save(place)
Ok(Json.obj("message" -> ("Place '" + place.name + "' saved.")))
}
)
}
This Action
is more complicated than our list case. Some things to note:
- This
Action
expects a request with aContent-Type
header oftext/json
orapplication/json
and a body containing a JSON representation of the entity to create. - It uses a JSON specific
BodyParser
which will parse the request and providerequest.body
as aJsValue
. - We used the
validate
method for conversion which will rely on our implicitReads[Place]
. - To process the validation result, we used a
fold
with error and success flows. This pattern may be familiar as it is also used for form submission. - The
Action
also sends JSON responses.
Body parsers can be typed with a case class, an explicit Reads
object or take a function. So we can offload even more of the work onto Play to make it automatically parse JSON to a case class and validate it before even calling our Action
:
import play.api.libs.functional.syntax._
import play.api.libs.json._
import play.api.libs.json.Reads._
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 placeReads: Reads[Place] =
(JsPath \ "name").read[String](minLength[String](2)).and((JsPath \ "location").read[Location])(Place.apply _)
// This helper parses and validates JSON using the implicit `placeReads`
// above, returning errors if the parsed json fails validation.
def validateJson[A: Reads] = parse.json.validate(
_.validate[A].asEither.left.map(e => BadRequest(JsError.toJson(e)))
)
// if we don't care about validation we could replace `validateJson[Place]`
// with `BodyParsers.parse.json[Place]` to get an unvalidated case class
// in `request.body` instead.
def savePlaceConcise: Action[Place] = Action(validateJson[Place]) { request =>
// `request.body` contains a fully validated `Place` instance.
val place = request.body
Place.save(place)
Ok(Json.obj("message" -> ("Place '" + place.name + "' saved.")))
}
Finally we’ll add a route binding in conf/routes
:
POST /places controllers.Application.savePlace
We’ll test this action with valid and invalid requests to verify our success and error flows.
Testing the action with a valid data:
curl --include
--request POST
--header "Content-type: application/json"
--data '{"name":"Nuthanger Farm","location":{"lat" : 51.244031,"long" : -1.263224}}'
http://localhost:9000/places
Response:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 57
{"message":"Place 'Nuthanger Farm' saved."}
Testing the action with a invalid data, missing “name” field:
curl --include
--request POST
--header "Content-type: application/json"
--data '{"location":{"lat" : 51.244031,"long" : -1.263224}}'
http://localhost:9000/places
Response:
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 79
{"message":{"obj.name":[{"msg":"error.path.missing","args":[]}]}}
Testing the action with a invalid data, wrong data type for “lat”:
curl --include
--request POST
--header "Content-type: application/json"
--data '{"name":"Nuthanger Farm","location":{"lat" : "xxx","long" : -1.263224}}'
http://localhost:9000/places
Response:
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 92
{"message":{"obj.location.lat":[{"msg":"error.expected.jsnumber","args":[]}]}}
§Summary
Play is designed to support REST with JSON and developing these services should hopefully be straightforward. The bulk of the work is in writing Reads
and Writes
for your model, which is covered in detail in the next section.