Help us understand the problem. What is going on with this article?

Akka-HTTPを型で縛る

More than 3 years have passed since last update.

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!))"
  }
}
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした