LoginSignup
7
5

More than 5 years have passed since last update.

Akka-HTTPを型で縛る

Posted at

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!))"
  }
}
7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5