blog.petitviolet.net

Akka-HTTPを型で縛る

2016-05-27

QiitaScalaAkkaAkka-HTTP

http リクエスト/レスポンスでやり取りする String な値をアプリケーションが期待する Scala の型に変換する方法について。

リクエストを型で縛る

ユーザーからのリクエストをなるべくStringとして触らないようにする。

URL パラメータ

以下のルーティングを考える。

GET /message?id=<ID>&body=<BODY>'

URL パラメータのidbodyをなるべくString以外で扱う。 まず、型を用意する。

sealed trait Content {
  val id: Long
  val response = complete(s"Requested: $this")
}
case class EmptyMessage(id: Long) extends Content
case class Message(id: Long, body: AwesomeBody) extends Content

case class AwesomeBody(value: String)

AwesomeBodyのコンストラクタにStringを受け付けるようになっている。 まずbodyStringAwesomeBodyに変換するための Unmarshaller を用意する。

val bodyUnmarshaller: Unmarshaller[String, AwesomeBody] =
  Unmarshaller.apply { (ec: ExecutionContext) => (s: String) => Future.successful(AwesomeBody(s)) }

次に、 URL パラメータから得られるid=<ID>&body=<BODY>Content、つまりEmptyMessageMessageとして扱えるようにするためにファクトリを実装する。

object Content {
  // ファクトリ
  def apply(id: Long, content: Option[AwesomeBody]): Content =
    content.map(Message(id, _)) getOrElse EmptyMessage(id)
}

Unmarshaller とファクトリがあれば以下のように書ける。

val route =
  path("message") {
    get {
      parameters('id.as[Long], 'body.as(bodyUnmarshaller)?).as(Content.apply _) { c: Content =>
        c.response
      }
    }
  }

リクエスト Body

上では URL パラメータを扱ったが、次は Body に入った JSON について。 メソッドは POST として同じ Path(/message)とし、JSON は上に合わせてid(必須)とbody(任意)を持つとする。 つまり、以下の様なリクエストとなる。

curl 'http://localhost:8080/message' -H 'Content-Type: application/json' -d '{"id":99}'
curl 'http://localhost:8080/message' -H 'Content-Type: application/json' -d '{"id":100,"body":"hello"}'

まずは JSON からContentへの Unmarshaller となるRootJsonReader[Content]を実装する。 今回のケースでは、DefaultJsonProtocol.jsonFormatNを使ってEmptyMessageMessageRootJsonFormatを用意してもだめで、 RootJsonFormat[Content]を自前で用意しなければならない。

import spray.json.DefaultJsonProtocol

object ContentJsonProtocol extends DefaultJsonProtocol {
  implicit val contentFormat = new RootJsonReader[Content] {
    override def read(json: JsValue): Content =
      json.asJsObject.getFields("id", "body") match {
        case Seq(JsNumber(id)) => EmptyMessage(id.toLong)
        case Seq(JsNumber(id), JsString(body)) => Message(id.toLong, AwesomeBody(body))
        case _ => throw new DeserializationException("Content")
      }
  }
}

用意したRootJsonFormat[Content]を implicit で宣言しておけばentity(as[T])とすればDirective1[T]が得られ、以下のように route が実装できる。

import ContentJsonProtocol.contentFormat

val route =
  path("message") {
    post {
      entity(as[Content]) { c: Content =>
        complete(s"pong: $c")
      }
    }
  }

この route に対するリクエストとレスポンスはこのようになる。

$ curl 'http://localhost:8080/message' -H 'Content-Type: application/json' -d '{"id":99}'
pong: EmptyMessage(99)

$ curl 'http://localhost:8080/message' -H 'Content-Type: application/json' -d '{"id":100,"body":"hello"}'
pong: Message(100,AwesomeBody(hello))

ヘッダー

標準で用意されているヘッダー

この場合は簡単で、headerValueByType[T]を使う。 Tとして使用できる型はakka.http.scaladsl.model.headers.headers.scalaに定義されている。 例えば User-Agent を使用したい場合は以下のように書ける。

val route =
  path("header" / "ua") {
    headerValueByType[`User-Agent`]() { userAgent =>
      get {
        complete(s"User-Agent => $userAgent")
      }
    }
  }

この route に対するリクエストとレスポンスは以下のようになる。

$ curl localhost:8080/header/ua -A "Awesome-UA"
User-Agent => User-Agent: Awesome-UA

カスタムヘッダー

ボリュームが大きくなったので別記事 [Akka-HTTP]カスタムヘッダーの取り扱い方 - Qiita

レスポンスを型で縛る

オブジェクトを String に変換すること無く、オブジェクトのまま返却する。 リクエストの逆で、Marshaller を用意すれば良い。

まずレスポンス用の型を実装する。 なおリクエストの例で使った型も再利用している。

case class AwesomeBody(value: String) // reuse
case class Reply(replyId: Long, body: AwesomeBody)

// factory
object Reply {
  def apply(content: Content): Reply = Reply(
    replyId = new Random().nextInt(100).toLong,
    body = AwesomeBody(s"thank you! : $content")
  )
}

次に、このReplyクラスに対するRootJsonProtocol[Reply]を実装する。

object ReplyJsonProtocol extends DefaultJsonProtocol {
  implicit val bodyFormat = jsonFormat1(AwesomeBody)
  implicit val replyFormat: RootJsonFormat[Reply] = jsonFormat2(Reply.apply)
}

これらだけで良い。 前述の route に組み合わせると以下のように書ける。

import ReplyJsonProtocol._

val route =
  path("message") {
    post {
      entity(as[Content]) { c: Content =>
        complete(Reply(c))
      }
    }
  }

completeの引数にReplyオブジェクトを渡すだけで JSON に Marshalling し、application/jsonにしてくれる。

$ curl -XPOST 'http://localhost:8080/message' -H 'Content-Type: application/json' -d '{"id":100,"body":"hello!"}'
HTTP/1.1 200 OK
Server: akka-http/2.4.2
Date: Sun, 08 May 2016 14:12:37 GMT
Content-Type: application/json
Content-Length: 97

{
  "replyId": 74,
  "body": {
    "value": "thank you! : Message(100,AwesomeBody(hello!))"
  }
}

from: https://qiita.com/petitviolet/items/64400d96f247cb4f8ab6