Daniel Westheide | @kaffeecoder | https://innoq.com
Play Framework User Group Berlin Brandenburg
March 25, 2015
implicit val saleFormat = Json.format[Sale]
def get(saleId: String) = Authenticated.async {
saleRepository.findById(saleId) map {
case Some(sale) => Ok(Json.toJson(sale))
case None => NotFound
}
}
def post() = Authenticated.async { request =>
salesService
.startSale(request.user.id)
.map(sale => Created(Json.toJson(sale)))
}
GET /sales/:id @controllers.Sales.get(id)
POST /sales @controllers.Sales.post()
PUT /sales/:id @controllers.Sales.put(id)
curl -u me:secret -XPOST localhost:9000/sales
HTTP/1.1 201 Created
Content-Length: 87
Content-Type: application/json; charset=utf-8
{
"id": "2a345261-8243-4559-8f90-e6dfd8f63312",
"items": [],
"sellerId": "1",
"status": "Open"
}
HTTP && !SOAP == REST
, right?Shot through the heart- Jon Bon Jovi, 1986
And you're to blame
You give REEEEEEST a bad name!
Cache-Control
and Expires
response headersstale-if-error
directive: trade availability for correctness if necessarytrait DefaultController extends Controller {
def cacheDirectives: `Cache-Control` = `Cache-Control`(Seq.empty)
object CacheControlling extends ActionBuilder[Request] {
override def invokeBlock[A](
request: Request[A],
block: (Request[A]) => Future[Result]) =
block(request) map (_.withHeaders(cacheExpirationHeaders: _*))
}
private def cacheExpirationHeaders: Seq[(String, String)] =
List(cacheControlHeader, expiresValue).flatten
private def cacheControlHeader: Option[(String, String)] =
if (cacheDirectives.directives.isEmpty) None
else Some(CACHE_CONTROL -> cacheDirectives.value)
private def expiresValue: Option[(String, String)] =
cacheDirectives.directives.collect {
case `max-age`(seconds) =>
EXPIRES -> formatExpires(seconds.toInt)
}.headOption
}
class Articles(articleRepository: ArticleRepository)
extends DefaultController {
override def cacheDirectives =
`Cache-Control`(`max-age`(3600), `stale-if-error`(900))
def get(id: String) = CacheControlling.async {
articleRepository.findById(ArticleId(id)) map {
case Some(article) => Ok(Json.toJson(article))
case None => NotFound
}
}
}
curl localhost:9000/articles/1
HTTP/1.1 200 OK
Cache-Control: max-age=3600, stale-if-error=900
Expires: Tue, 24 Mar 2015 11:32:48 GMT
Variant property | Request header | Play support |
---|---|---|
Language | Accept-Language |
implicit Lang in controllers |
Character set | Accept-Charset |
none |
Compression | Accept-Encoding |
GzipFilter on a global level |
Format | Accept |
request extractors |
curl localhost:9000/sales/1 -H"Accept: text/*;q=0.1,application/json"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Vary: Accept
{
"id": "1",
"items": [],
"sellerId": "1",
"status": "Open"
}
»I would really like to get a JSON representation, but any text representation is fine, too.«
def get(saleId: String) = Authenticated.async { implicit req =>
saleRepository.findById(SaleId(saleId)) map {
case Some(sale) => render {
case Accepts.Html() => Ok(views.html.sale(sale))
case Accepts.Json() => Ok(Json.toJson(sale))
}
case None => NotFound
}
}
Building blocks:
MediaRange
to Result
Accepting
boolean extractorsNot Acceptable
with hints about supported media types:
curl localhost:9000/sales/1 -H"Accept: application/xml"
HTTP/1.1 406 Not Acceptable
Content-Length: 0
Vary: Accept
404 Not Found
instead of a 406 Not Acceptable
curl localhost:9000/sales/doesnotexist -H"Accept: application/xml"
HTTP/1.1 404 Not Found
Content-Length: 0
def get(saleId: String) = Authenticated.async { implicit req =>
def respond[A : Writeable](f: Sale => A) = {
saleRepository.findById(SaleId(saleId)) map {
case Some(sale) => Ok(f(sale))
case None => NotFound
}
}
render.async {
case Accepts.Html() => respond(views.html.sale(_))
case Accepts.Json() => respond(Json.toJson(_))
}
}
resolvers +=
"restful-scala" at "https://dl.bintray.com/restfulscala/maven"
libraryDependencies +=
"org.restfulscala" %% "play-content-negotiation" % "0.2.0"
class Sales(saleRepository: SaleRepository) extends
Controller with ContentNegotiation {
val negotiate = represent[Sale](
as(Accepts.Html, views.html.sale(_)),
as(Accepts.Json, Json.toJson(_))
)
def get(saleId: String) = Authenticated.async { implicit req =>
negotiate.async { variant =>
saleRepository.findById(SaleId(saleId)) map {
case Some(sale) => variant.respond(sale, 200)
case None => NotFound
}
}
}
def post() = Authenticated.async { implicit req =>
negotiate.async { variant =>
salesService
.startSale(SellerId(req.user.id))
.map(sale => variant.respond(sale, 201)
.withHeaders(
LOCATION -> location(sale.id),
CONTENT_LOCATION -> location(sale.id)))
}
}
}
curl localhost:9000/sales/1 -H"Accept: application/xml"
HTTP/1.1 406 Not Acceptable
Content-Length: 51
Content-Type: text/plain; charset=utf-8
Vary: Accept
Acceptable media types: text/html, application/json
(aka Postel's Law)
Be liberal in what you accept, and conservative in what you send- RFC 1122
implicit val articleReads: Reads[Article] = Json.reads[Article]
// is actually:
implicit val articleReads = (
(__ \ 'articleId).read[ArticleId] and
(__ \ 'name).read[String] and
(__ \ 'price).read[Money]
)(Article)
case class Article(
id: ArticleId,
name: String,
price: Money,
description: Option[String])
implicit val articleReads: Reads[Article] = (
(__ \ 'articleId).read[ArticleId] and
(__ \ 'name).read[String] and
(__ \ 'price).read[Money] and
(__ \ 'description).readNullable[String]
)(Article)
case class Article(
id: ArticleId,
name: String,
price: Money,
description: String)
implicit val articleReads: Reads[Article] = (
(__ \ 'articleId).read[ArticleId] and
(__ \ 'name).read[String] and
(__ \ 'price).read[Money] and
(__ \ 'description).readNullable[String]
.map(_ getOrElse "No description specified")
)(Article)
{
"http://api.wetakeyourstuff.com/rels/articles" : {
"href" : "/articles"
},
"http://api.wetakeyourstuff.com/rels/sales" : {
"href" : "/sales",
"hints" : { "allow" : ["GET", "POST"]}
},
"http://api.wetakeyourstuff.com/rels/sale" : {
"href-template" : "/sales/{saleId}",
"href-vars" : {
"saleId" : "http://api.wetakeyourstuff.com/saleId"
}
}
}
A JSON Home document
{
"_embedded": {
"articles": [...]
},
"_links": {
"next": {
"href": "/articles?offset=2"
},
"self": {
"href": "/articles"
}
},
"total": 10,
"selectedmax": 2
}
A HAL+JSON representation of a collection resource of articles
David Hasselhoff at re:publica 2014 by re:publica (CC BY-SA 2.0)
val halselhof =
RootProject(uri("git://github.com/restfulscala/HALselhof.git"))
lazy val root = (project in file("."))
.enablePlugins(PlayScala)
.dependsOn(halselhof)
HalResource
, HalLink
)Writes
instancesimplicit val saleWrites: Writes[Sale] = Json.writes[Sale]
import play.api.hal.Hal
def saleToHal(sale: Sale): HalResource = Hal.state(sale)
Creates a HalResource
whose state is the JSON produced by the Writes[Sale]
{
"id": "548a95cb-d975-4945-b432-43560593df1a",
"items": [],
"sellerId": "1",
"status": "Open"
}
import play.api.hal.Hal
def saleToHalLinks(sale: Sale)(implicit req: RequestHeader) = {
Hal.links(
HalLink(
rel = "self",
href = routes.Sales.get(sale.id.value).absoluteURL(),
`type` = Some(halMimeType)
),
HalLink(
"http://example.org/rels/sale-items",
routes.Sales.items(sale.id.value).absoluteURL(),
`type` = Some(halMimeType)
)
)
}
{
"_links": {
"http://example.org/rels/sale-items": {
"href": "http://localhost:9000/sales/1/items",
"type": "application/hal+json"
},
"self": {
"href": "http://localhost:9000/sales/1",
"type": "application/hal+json"
}
}
}
import play.api.hal.Hal._
implicit val saleWrites: Writes[Sale] = Json.writes[Sale]
def saleToHal(sale: Sale)(implicit req: RequestHeader): HalResource = {
Hal.state(sale) ++ saleToHalLinks(sale)
}
{
"_links": {
"http://example.org/rels/sale-items": {
"href": "http://localhost:9000/sales/1/items",
"type": "application/hal+json"
},
"self": {
"href": "http://localhost:9000/sales/1",
"type": "application/hal+json"
}
},
"id": "20a39883-3d41-4dd9-9ef8-31b252bec05f",
"items": [],
"sellerId": "1",
"status": "Open"
}
def get(saleId: String) = Authenticated.async { implicit req =>
import play.api.mvc.hal._
saleRepository.findById(SaleId(saleId)) map {
case Some(sale) => Ok(saleToHal(sale))
case None => NotFound
}
}
Content-type
response header will be application/hal+json
.
import play.api.mvc.hal._
def negotiate()(implicit req: RequestHeader) = represent[Sale](
as(Accepts.Html, views.html.sale(_)),
as(AcceptHal, saleToHal)
)
Also works with Play's standard content negotiation API
curl "localhost:9000/sales/1
HTTP/1.1 200 OK
Content-Type: application/vnd.siren+json
{
"class": [ "sale" ],
"properties": {
"id": "1",
"status": "Open",
"total": "EUR 2.32"
},
"entities": [
{
"class": [ "items", "collection" ],
"rel": [ "http://example.org/rels/sale-items" ],
"href": "http://localhost:9000/sales/1/items"
}
],
"actions": [
{
"name": "add-item",
"method": "POST",
"href": "http://localhost:9000/sales/1/items",
"fields": [
{ "name": "articleId", "type": "text" }
]
},
{
"name": "cancel",
"method": "DELETE",
"href": "http://localhost:9000/sales/1"
}
]
}
curl "localhost:9000/sales/1
HTTP/1.1 200 OK
Content-Type: application/vnd.siren+json
{
"class": [ "sale" ],
"properties": {
"id": "1",
"status": "Open",
"total": "EUR 12.32"
},
"entities": [
{
"class": [ "items", "collection" ],
"rel": [ "http://example.org/rels/sale-items" ],
"href": "http://localhost:9000/sales/1/items"
}
],
"actions": [
{
"name": "add-item",
"method": "POST",
"href": "http://localhost:9000/sales/1/items",
"fields": [
{ "name": "articleId", "type": "text" }
]
},
{
"name": "cancel",
"method": "DELETE",
"href": "http://localhost:9000/sales/1"
}
{
"name": "commit",
"method": "PUT",
"href": "http://localhost:9000/sales/1/commital"
}
]
}
curl "localhost:9000/sales/1
HTTP/1.1 200 OK
Content-Type: application/vnd.siren+json
{
"class": [ "sale" ],
"properties": {
"id": "1",
"status": "Committed",
"total": "EUR 12.32"
},
"entities": [
{
"class": [ "items", "collection" ],
"rel": [ "http://example.org/rels/sale-items" ],
"href": "http://localhost:9000/sales/1/items"
}
],
"actions": [
{
"name": "cancel",
"method": "DELETE",
"href": "http://localhost:9000/sales/1"
}
]
}
curl "localhost:9000/sales/1
HTTP/1.1 200 OK
Content-Type: application/vnd.siren+json
{
"class": [ "sale" ],
"properties": {
"id": "1",
"status": "InDelivery",
"total": "EUR 12.32"
},
"entities": [
{
"class": [ "items", "collection" ],
"rel": [ "http://example.org/rels/sale-items" ],
"href": "http://localhost:9000/sales/1/items"
}
]
}
resolvers +=
"restful-scala" at "https://dl.bintray.com/restfulscala/maven"
libraryDependencies += "org.restfulscala" %% "play-siren" % "0.2.0"
implicit def saleWriter
(implicit req: RequestHeader): SirenRootEntityWriter[Sale] =
new SirenRootEntityWriter[Sale] {
override def toSiren(sale: Sale): RootEntity = {
RootEntity(
classes = Some(List("sale")),
properties = Some(
List(
Property("id", StringValue(sale.id.value)),
Property("seller_id", StringValue(sale.sellerId.value)),
Property("status", StringValue(sale.status.toString))
)
),
entities = embeddedLinks(sale),
actions = Some(actions(sale)).filter(_.nonEmpty)
)
}
override def toSiren(sale: Sale): RootEntity = {
val addItem = Action(
name = "add-item",
method = Some(Action.Method.POST),
href = routes.Sales.addItem(sale.id.value).absoluteURL(),
fields = Some(List(Action.Field("articleId", Action.Field.Type.text)))
) -> Sale.canAddItems _
...
def actions(sale: Sale): List[Action] = {
List(addItem, cancelSale, commit) collect {
case (action, condition) if condition(sale) => action
}
}
def get(saleId: String) = Authenticated.async { implicit req =>
import com.yetu.siren._
import play.api.mvc.hal._
saleRepository.findById(SaleId(saleId)) map {
case Some(sale) => Ok(Siren.asRootEntity(sale))
case None => NotFound
}
}
Content-type
response header is application/vnd.siren+json
case class Writeable[-A](
transform: (A) => Array[Byte],
contentType: Option[String])
class Status(status: Int) extends Result {
def apply[C](content: C)(implicit w: Writeable[C]): Result = ???
case class ContentTypeOf[-A](mimeType: Option[String])
val sirenJsonMediaType = "application/vnd.siren+json"
def sirenJsonContentType(implicit codec: Codec): String =
ContentTypes withCharset sirenJsonMediaType
implicit def contentTypeOfSirenJson
(implicit codec: Codec): ContentTypeOf[RootEntity] =
ContentTypeOf[RootEntity](Some(sirenJsonContentType))
implicit def writableOfSirenJson
(implicit codec: Codec): Writeable[RootEntity] =
Writeable(e => codec.encode(Json.toJson(e).toString()))
import org.restfulscala.playsiren._
import com.yetu.siren._
def negotiate()(implicit req: RequestHeader) = represent[Sale](
as(Accepts.Html, views.html.sale(_)),
as(AcceptsSirenJson, Siren.asRootEntity(_))
)
Also works with Play's standard content negotiation API
val AcceptsSirenJson = Accepting(sirenJsonMediaType)
GET /articles/:id @controllers.Articles.get(id)
GET /articles @controllers.Articles.list()
GET /sales/:id @controllers.Sales.get(id)
GET /sales @controllers.Sales.list()
POST /sales @controllers.Sales.post()
PUT /sales/:id @controllers.Sales.put(id)
POST /sales/:id/items @controllers.Sales.addItem(id)
DELETE /sales/:id @controllers.Sales.cancel(id)
PUT /sales/:id/commital @controllers.Sales.commit(id)
GET /sales/:id/items @controllers.Sales.items(id)
trait GlobalSettings {
def onRouteRequest(request: RequestHeader): Option[Handler]
}
object Global extends GlobalSettings {
val cells = "cells" / Param("cellId") :~> CellResource
val switches = "switches" / Param("switchId") :~> SwitchResource
val myDispatcher = Dispatcher.from(cells, switches)
override def onRouteRequest(request: RequestHeader) = {
myDispatcher(request)
}
}
object SwitchResource extends Resource[Switch, SwitchId] {
override def allowedMethods = Set(HEAD, GET, POST, OPTIONS)
override def extractRequestParams(
request: Request[_], pathParams: Seq[PathParam]) = {
extractPathParam("switchId", pathParams) map SwitchId
}
override def isResourceExists(
request: Request[_], switchId: SwitchId) =
SwitchRepository findById switchId
override def handleGet(resource: Switch) = {
case Accepts.Html() => Ok(views.html.switch(resource))
case AcceptsSirenJson() => Ok(Siren.asRootEntity(resource))
}
override def handlePost(
request: Request[_], switchId: SwitchId) =
isResourceExists(request, switchId) map {
case Some(switch) =>
val updated = switch.flip()
SwitchRepository.save(updated)
Right(updated)
case None => Left(404)
}
override implicit def executionContext = defaultContext
}
Twitter: @kaffeecoder
Blog/Website: http://danielwestheide.com
Email: daniel.westheide@innoq.com