2016年10月24日

HTTP programming in Scala Play

如何在 scala play 中取得 broswer 的 request,並將結果回傳給 browser,scala play 是以 routes 設定為進入點,確認要使用哪一個 controller 處理哪一個 url request,最重要的是,簡化了非同步處理的處理方法,我們可以很輕鬆地就隔絕後端 DB 的處理,避免因 DB 異常影響到前端頁面卡頓的狀況。

Actions, Controllers and Results

在 Play 收到的 browser request 就是由 Action 處理,並產生 Result

plya.api.mvc.Action 就等同於 (play.api.mvc.Request => plya.api.mv.Result) 函數

controller 就是 Action generator,在 controller 中可以定義 method 為

package controllers

import play.api.mvc._

class Application extends Controller {
  def echo = Action { request =>
    Ok("Got request [" + request + "]")
  }
  
  def hello(name: String) = Action {
    Ok("Hello " + name)
  }
}

產生 Action 的範例

Action {
  Ok("Hello world")
}

Action { request =>
  Ok("Got request [" + request + "]")
}

Action { implicit request =>
  Ok("Got request [" + request + "]")
}

// 指定 BodyParser
Action(parse.json) { implicit request =>
  Ok("Got request [" + request + "]")
}

index 及 index2 會產生相同的頁面 Result

import play.api.http.HttpEntity

def index = Action {
  Result(
    header = ResponseHeader(200, Map.empty),
    body = HttpEntity.Strict(ByteString("Hello world!"), Some("text/plain"))
  )
}

def index2 = Action {
  Ok("Hello world!")
}

以下這些為各種不同類型的 play.api.mvc.Results 的範例

val ok = Ok("Hello world!")
val notFound = NotFound
val pageNotFound = NotFound(<h1>Page not found</h1>)
val badRequest = BadRequest(views.html.form(formWithErrors))
val oops = InternalServerError("Oops")
val anyStatus = Status(488)("Strange response type")

處理 Redirect

// 303 SEE_OTHER
def index = Action {
  Redirect("/user/home")
}

// 301 MOVED_PERMANENTLY
def index = Action {
  Redirect("/user/home", MOVED_PERMANENTLY)
}

如果只是定義介面,還不實作,可以用 TODO

def index(name:String) = TODO

HTTP Routing

定義 /conf/routes

Play 支援兩種 router: 預設建議使用第一種

  1. dependency injected router
  2. static router

如果要更換 router 為 static router,可以在 build.sbt 中調整設定。如果在 routes 符合兩個以上的路由,那就依照路由宣告的順序使用第一個。

routesGenerator := StaticRoutesGenerator

route 的第一個欄位是填寫 HTTP method,有以下這六種

GET, PATCH, POST, PUT, DELETE, HEAD


route 範例

# 固定路徑
GET   /clients/all          controllers.Clients.list()

# 動態取得路徑中的一部份,可以用 regular expression: [^/]+
GET   /clients/:id          controllers.Clients.show(id: Long)

# *id 就等同於 .+
GET   /files/*name          controllers.Application.download(name)

# 使用自訂的 regular expression,如果要自動轉型,才填寫 id:Long,否則可以省略不寫
GET   /items/$id<[0-9]+>    controllers.Items.show(id: Long)

# 可以給予參數的預設值
GET   /items/               controllers.Items.show(id: Long=10)

Manipulating results

Result 的 Content-Type

// Content-Type: text/plain
val textResult = Ok("Hello World!")

// Content-Type: application/xml
val xmlResult = Ok(<message>Hello World!</message>)

// text/html
val htmlResult = Ok(<h1>Hello World!</h1>).as("text/html")

// text/html; charset=utf-8
val htmlResult2 = Ok(<h1>Hello World!</h1>).as(HTML)

可以增加或修改 HTTP Response Header

val result = Ok("Hello World!").withHeaders(
  CACHE_CONTROL -> "max-age=3600",
  ETAG -> "xx")

設定 Response Cookie

// 增加 cookie: theme
val result = Ok("Hello world").withCookies(
  Cookie("theme", "blue"))

// 清除 cookie: theme
val result2 = result.discardingCookies(DiscardingCookie("theme"))

// 增加 cookie: theme, 同時刪除 cookie: skin
val result3 = result.withCookies(Cookie("theme", "blue")).discardingCookies(DiscardingCookie("skin"))

預設的 charset 為 utf-8,可以修改

利用 scala 的 implicit instance,可以改變 HTML 的 charset

class Application extends Controller {

  implicit val myCustomCharset = Codec.javaSupported("iso-8859-1")

  def index = Action {
    // text/html; charset=iso-8859-1 
    Ok(<h1>Hello World!</h1>).as(HTML)
  }
}

因為 HTML 是用以下的方式定義的

def HTML(implicit codec: Codec) = {
  "text/html; charset=" + codec.charset
}

Session and Flash scopes

如果需要在不同 HTTP requests 之間共用資料,可以儲存在 Session 或是 Flash scopes,Session 會在整個 user Session 都可以使用,Flash scope 只能在下一個 request 中使用。

session 名稱預設為 PLAYSESSION,可以在 application.conf 用 session.cookieName 修改,(為了簡化相容性的問題,其實可以直接改成 JSESSIONID),PLAYSESSION 有使用 private key 加密以避免 client 竄改。

application.conf 的 play.http.session.maxAge (ms) 可設定 session 存活的時間

如果要暫存一些資料,可以在 SESSION 中存入 unique ID ,然後在 play 的 cache 機制中儲存資料


session 的新增與刪除

// 存入 session
Ok("Welcome!").withSession(
  "connected" -> "user@gmail.com")

Ok("Hello World!").withSession(
  request.session + ("saidHello" -> "yes"))

// 移除 session: theme
Ok("Theme reset!").withSession(
  request.session - "theme")

// 讀取 session value,判斷 user 是否已經登入系統
def index = Action { request =>
  request.session.get("connected").map { user =>
    Ok("Hello " + user)
  }.getOrElse {
    Unauthorized("Oops, you are not connected")
  }
}

// 直接用新的 session 將舊的 session 刪除
Ok("Bye").withNewSession

// 取得 flash scope value: success
def index = Action { implicit request =>
  Ok {
    request.flash.get("success").getOrElse("Welcome!")
  }
}

// 新增 flash scope: success
def save = Action {
  Redirect("/home").flashing(
    "success" -> "The item has been created")
}

如果要在畫面上使用 flash scope value: success

@()(implicit flash: Flash)

@flash.get("success").getOrElse("Welcome!")

controller 必須用 implicit instance request 傳送到 views

def index = Action { implicit request =>
  Ok(views.html.index())
}

Body parsers

一般的 http request 資料會暫存記憶體中,可以用 BodyParser 來處理。如果 http request body 內容太長,就需要 以 stream 的方式來處理,以免資料需要整個暫存在記憶體中。

Play 是使用 Akka Strems 處理 stream 資料。


內建的 body parsers 可以處理 JSON, XML, forms

Action 是以 Request[A] => Result 定義的,只要有可以處理 A 的 BodyParser,就可以處理對應的 Request Content

trait Action[A] extends (Request[A] => Result) {
  def parser: BodyParser[A]
}

trait Request[+A] extends RequestHeader {
  def body: A
}

預設會以 request header: Content-Type 作為選用 BodyParser 的條件。例如 Content-Type 為 application/json,就會使用 JsValue 的 BodyParser,Content-Type 為 application/x-form-wwww-urlencoded,就 paring 為 Map[String, Seq[String]]


預設 body parser 會產生 AnyContent 資料型別,然後再以 asJson 轉換成 JsValue

def save = Action { request =>
  val body: AnyContent = request.body
  val jsonBody: Option[JsValue] = body.asJson

  // Expecting json body
  jsonBody.map { json =>
    Ok("Got: " + (json \ "name").as[String])
  }.getOrElse {
    BadRequest("Expecting application/json request body")
  }
}

以下是 default body parser 支援的資料對應

  • text/plain: String, asText
  • application/json: JsValue, asJson
  • application/xml, text/xml 或是 application/XXX+xml: scala.xml.NodeSeq, asXml
  • application/form-url-encoded: Map[String, Seq[String]], asFormUrlEncoded
  • multipart/form-data: MultipartFormData, asMultipartFormData
  • 其他的content: RawBuffer, asRaw

選用適當的 body parser

def save = Action(parse.json) { request =>
  Ok("Got: " + (request.body \ "name").as[String])
}

可以用 curl 測試,會得到 Got: user name

curl 'http://localhost:9000/test' -H 'Content-Type:application/json' --data-binary '{"name": "user name","interval": "month"}'

如果使用 parse.tolerantJson,就會忽略 Content-Type,直接嘗試將 content body 以 json parsing

def save = Action(parse.tolerantJson) { request =>
  Ok("Got: " + (request.body \ "name").as[String])
}

以下例子可以將 request body 儲存到一個檔案中

def save = Action(parse.file(to = new File("/tmp/upload"))) { request =>
  Ok("Saved the request content to " + request.body)
}

storeInUserFile 是一個自訂的 body parser,可以從 session 取得 username,並將 body 以 user 名稱儲存為檔案

val storeInUserFile = parse.using { request =>
  request.session.get("username").map { user =>
    file(to = new File("/tmp/" + user + ".upload"))
  }.getOrElse {
    sys.error("You don't have the right to upload here")
  }
}

def save = Action(storeInUserFile) { request =>
  Ok("Saved the request content to " + request.body)
}

application.conf 中是以這個設定值,決定最大的 content length

play.http.parser.maxMemoryBuffer=128K

可以直接用 maxLength 覆寫這個設定值,parse.maxLength 可以封裝任何一個 body parser

// Accept only 10KB of data.
def save = Action(parse.text(maxLength = 1024 * 10)) { request =>
  Ok("Got: " + text)
}

// Accept only 10KB of data.
def save = Action(parse.maxLength(1024 * 10, storeInUserFile)) { request =>
  Ok("Saved the request content to " + request.body)
}

Custom body parser

note: 這部分我決定先跳過不看,等比較熟悉之後,再去了解

Actions composition

如果需要做一個 Action logging decorator,記錄所有呼叫這個 action 的 request。

首先要實作 invokeBlock method,ActionBuilder 產生的每一個 action 都會呼叫。

import play.api.mvc._

object LoggingAction extends ActionBuilder[Request] {
  def invokeBlock[A](request: Request[A], block: (Request[A]) => Future[Result]) = {
    Logger.info("Calling action")
    block(request)
  }
}

使用剛剛的 LoggingAction

def index = LoggingAction {
  Ok("Hello World")
}

def submit = LoggingAction(parse.text) { request =>
  Ok("Got a body " + request.body.length + " bytes long")
}

用上面的寫法,會需要寫很多個 action buidler,如果要改成將既有的 Action 增加 Logging 的功能,可以用 wrapping actions 的方法實作。

import play.api.mvc._

case class Logging[A](action: Action[A]) extends Action[A] {

  def apply(request: Request[A]): Future[Result] = {
    Logger.info("Calling action")
    action(request)
  }

  lazy val parser = action.parser
}

也可以不產生 action class,直接定義 logging method

import play.api.mvc._

def logging[A](action: Action[A])= Action.async(action.parser) { request =>
  Logger.info("Calling action")
  action(request)
}

Actions 可以利用 composeAction 方法 mixed in to action builder

object LoggingAction extends ActionBuilder[Request] {
  def invokeBlock[A](request: Request[A], block: (Request[A]) => Future[Result]) = {
    block(request)
  }
  override def composeAction[A](action: Action[A]) = new Logging(action)
}

現在就可以這樣使用 LogginAction,或是用 mixin wrapping actions without action builder 的方法

def index = LoggingAction {
  Ok("Hello World")
}

def index = Logging {
  Action {
    Ok("Hello World")
  }
}

這個例子是修改 request,增加一個 remoteAddress 欄位

import play.api.mvc._

def xForwardedFor[A](action: Action[A]) = Action.async(action.parser) { request =>
  val newRequest = request.headers.get("X-Forwarded-For").map { xff =>
    new WrappedRequest[A](request) {
      override def remoteAddress = xff
    }
  } getOrElse request
  action(newRequest)
}

這個例子是拒絕 http 連線,只允許 https 通過

import play.api.mvc._

def onlyHttps[A](action: Action[A]) = Action.async(action.parser) { request =>
  request.headers.get("X-Forwarded-Proto").collect {
    case "https" => action(request)
  } getOrElse {
    Future.successful(Forbidden("Only HTTPS requests allowed"))
  }
}

修改 response,增加一個 response header: X-UA-Compatible

import play.api.mvc._
import play.api.libs.concurrent.Execution.Implicits._

def addUaHeader[A](action: Action[A]) = Action.async(action.parser) { request =>
  action(request).map(_.withHeaders("X-UA-Compatible" -> "Chrome=1"))
}

四個 pre-defined traits,實作了 ActionFunction,用來對不同資料進行處理。我們也可以實作 invokeFunction,實作自訂的 ActionFunction。

  1. ActionTransformer 可修改 request, 增加資料
  2. ActionFilter 選擇性攔截 request,可用來產生 error
  3. ActionRefiner 以上兩種的 general case
  4. ActionBuilder 以 Request 為參數,可以產生新的 actions

使用範例:Authentication

import play.api.mvc._

class UserRequest[A](val username: Option[String], request: Request[A]) extends WrappedRequest[A](request)

object UserAction extends
    ActionBuilder[UserRequest] with ActionTransformer[Request, UserRequest] {
  def transform[A](request: Request[A]) = Future.successful {
    new UserRequest(request.session.get("username"), request)
  }
}

使用範例:增加資料到 request

// add Item to UserRequest
import play.api.mvc._

class ItemRequest[A](val item: Item, request: UserRequest[A]) extends WrappedRequest[A](request) {
  def username = request.username
}

// looks up that item and returns Either an error (Left) or a new ItemRequest (Right)
def ItemAction(itemId: String) = new ActionRefiner[UserRequest, ItemRequest] {
  def refine[A](input: UserRequest[A]) = Future.successful {
    ItemDao.findById(itemId)
      .map(new ItemRequest(_, input))
      .toRight(NotFound)
  }
}

使用範例: Validating requests

object PermissionCheckAction extends ActionFilter[ItemRequest] {
  def filter[A](input: ItemRequest[A]) = Future.successful {
    if (!input.item.accessibleByUser(input.username))
      Some(Forbidden)
    else
      None
  }
}

利用 ActionBuilder,以及 andThen,把以上的 action function 合併在一起

def tagItem(itemId: String, tag: String) =
  (UserAction andThen ItemAction(itemId) andThen PermissionCheckAction) { request =>
    request.item.addTag(tag)
    Ok("User " + request.username + " tagged " + request.item.id)
  }

Content negotiation

這個機制可以用相同的 URI 處理不同的 content boday,例如同一個 URI 網址可以同時接受 XML, JSON。也可以利用 Accept-Language 欄位,決定回傳的 response 要用什麼語系的內容。

play.api.mvc.RequestHeader#acceptLanguages 可取得 Accept-Language 的資料

play.api.mvc.RequestHeader#acceptedTypes 可取得可接受的 request MIME types 列表,也就是 Accept 欄位。實際上 Accept 欄位並沒有確切的 MIME type,而是 media range 例如 text/*, /,controller 提供 render method,處理 media range。

val list = Action { implicit request =>
  val items = Item.findAll
  render {
    case Accepts.Html() => Ok(views.html.list(items))
    case Accepts.Json() => Ok(Json.toJson(items))
  }
}

可以使用 play.api.mvc.Accepting case class 產生特定 MIME type 的自訂 extractor。

  val AcceptsMp3 = Accepting("audio/mp3")
  render {
    case AcceptsMp3() => ???
  }

Handling errors

HTTP application 的 error 主要有兩種:client errors, server errors。

Play 會自動偵測 client errors,包含 malformed header value, unsupported content types, requests for unknown resource。也會自動處理一些 server errors,例如 code throws an exception,Play 會自動產生 error page。

以下為 custom error handler,實作了兩個 methods: onClientError 及 onServerError。

import play.api.http.HttpErrorHandler
import play.api.mvc._
import play.api.mvc.Results._
import scala.concurrent._
import javax.inject.Singleton;

@Singleton
class ErrorHandler extends HttpErrorHandler {

  def onClientError(request: RequestHeader, statusCode: Int, message: String) = {
    Future.successful(
      Status(statusCode)("A client error occurred: " + message)
    )
  }

  def onServerError(request: RequestHeader, exception: Throwable) = {
    Future.successful(
      InternalServerError("A server error occurred: " + exception.getMessage)
    )
  }
}

可在 application.conf 設定 error handler

play.http.errorHandler = "com.example.ErrorHandler"

也可以利用 default error handler 延伸自己的功能。以下就是不改變 development error message,並修改特定的 onForbidden error page。

import javax.inject._

import play.api.http.DefaultHttpErrorHandler
import play.api._
import play.api.mvc._
import play.api.mvc.Results._
import play.api.routing.Router
import scala.concurrent._

@Singleton
class ErrorHandler @Inject() (
    env: Environment,
    config: Configuration,
    sourceMapper: OptionalSourceMapper,
    router: Provider[Router]
  ) extends DefaultHttpErrorHandler(env, config, sourceMapper, router) {

  override def onProdServerError(request: RequestHeader, exception: UsefulException) = {
    Future.successful(
      InternalServerError("A server error occurred: " + exception.getMessage)
    )
  }

  override def onForbidden(request: RequestHeader, message: String) = {
    Future.successful(
      Forbidden("You're not allowed to access this resource.")
    )
  }
}

References

HTTP programming