petitviolet blog

    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