Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Akka HTTPでContent-Typeを設定する方法

Akka HTTPは素朴なライブラリなので、特にそういうプラグインを使っていない場合は自分でレスポンスのContent-Typeを設定する必要がある。この記事では、Akka HTTPでContent-Typeを設定する方法と、Acceptヘッダを設定する方法を説明する。

この記事ではAkka 2.8.3, Akka HTTP 10.5.2で書かれている。

前提: Akka HTTPにおけるルーティング

前提として、Akka HTTPでは以下のようにDirectiveという部品を組み合わせてルーティングを構築していく。

import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route

// パスの先頭にマッチするdirective
val route: Route = pathPrefix("actor") {
  // パスにマッチするdirective。この時点でactor/outboxにマッチする
  path("outbox") {
    // GET メソッドにマッチするdirective
    get { 
      complete("Hello, Akka!") // completeディレクティブにレスポンスを渡す
    }
  }
}

で、ルーティングをHTTPサーバに渡して起動する。

Http().newServerAt("0.0.0.0", 8080).bind(routes)

このへんは公式サンプルを見るとわかりやすい。

ルーティングに話を戻すと、Akka HTTPでのおおまかな処理の流れは、

  1. Directiveでリクエストにマッチさせる
  2. completeなどでHttpResponseを返す

というもの。ただし、例えばcompleteといったよく使われるdirectiveはオーバロードされており、直接値を押し付けると文字列にシリアライズされて200 OKを返すような挙動になる。

前提2: Circeを使ったJSONハンドリング

今回はJSONのレスポンスにカスタムしたContent-Typeを設定したいという話なので、ちょっと補足。

akka-http-jsonというライブラリ群がある。その名の通り、Akka HTTPに様々なJSONライブラリを適合させるもので、簡単にリクエストボディやレスポンスボディにJSONを使えるようになる。

github.com

今回はCirceに対応させてみる。akka-http-circeを依存性に追加した。

libraryDependencies += "de.heikoseeberger" %% "akka-http-circe" % "1.39.2"

以下のように書くことで、レスポンスにJSONを返せるようになる。

import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport

case class FooCaseClass(num: Int, str: String, doubles: Seq[Double])

get {
  import io.circe.generic.auto._
  complete(FooCaseClass(42, "fooclass", Seq(3.14, 2.72)))
}

このルーティングからは以下のようなJSONが得られる:

{
  "num": 42,
  "str": "fooclass",
  "doubles": [ 3.14, 2.72 ]
}

このとき、Content-Typeは自動的にapplication/jsonに設定される。

カスタムしたContent-Typeを設定する

ここからが本題。自分はActivityPubのサーバを書いているのだが、ActivityPubではContent-Typeを特定の値に設定しなければ他サーバに受理してもらえないことが多い。このため、レスポンスのフォーマットはJSONにしつつ、Content-TypeはActivityPubに特化したものに設定する、という手順を踏む必要がある。

以下のように手でHttpResponseを作成するコードを書くことで、Content-Typeをカスタマイズ可能だ:

import akka.http.scaladsl.model.ContentType

get {
  import io.circe.generic.auto._
  import io.circe.syntax._
  // ContentTypeクラスをパースして生成する; ここではjrdというMIMEを使う
  val Right(jrd) = ContentType.parse("application/jrd+json; charset=utf-8")
  // JSON文字列を生成してバイト列に変換する
  val j = an_nice_case_class_value.asJson.noSpaces.getBytes()
  // ContentTypeとJSONバイト列を組み合わせてHttpEntityを生成し、HttpResponseを生成する
  val res = HttpResponse(entity = HttpEntity(jrd, j))
  complete(res)
}

Akka HTTPはContent-Typeあたりもしっかり型で守っているので、いったんパースする必要があることに注意する。基本的に、Entityを作る、Responseを作る、という順でやっていれば困ることはない。

既定のContent-Typeを設定する

akka.http.scaladsl.model.ContentTypes には数多くのContent-Typesが既に定義済みの形で利用できるようになっているので、まずはこちらを使ってみると良い。例えばXMLを指定する場合は以下のようにする。

import akka.http.scaladsl.model.ContentTypes
HttpResponse(entity = HttpEntity(ContentTypes.`text/xml(UTF-8)`, ...))

Akkaの一般的なパッケージ構造として、ContentTypesのような複数形の名前空間には定義済みのよく使うモデルが入っており、ContentTypeのような単数の名前でモデル自体の定義が行われていることが殆んどなので、覚えておくとよい。同様の構造はHttpCharsetHeaderでもあらわれる。

Content-Typeの構造

せっかくなので説明しておくと、Akka HTTPのContentTypeは、2つのパートで構成されている:

  • MediaType
    • BinaryWithFixedCharsetWithOpenCharsetの3種類からなる
  • HttpCharset
    • UTF-8とか。他にはUTF-16などもある

application/jsonの部分がMediaTypeで、charset=utf-8の部分がHttpCharsetだといったら理解できるはず。ちなみにバイナリのMediaTypeも存在するので、HttpCharsetContentTypeの内部ではOption[HttpCharset]になる。

例えばimage/pngはバイナリフォーマットなので、以下のような形でContent-Typeを設定することになる:

import akka.http.scaladsl.model.MediaTypes
// pngはBinaryとして定義済みなのでContentTypeにcharsetを渡す必要はない
val pngCT = ContentType(MediaTypes.`image/png`)

もうひとつの例としてActivityPubで使われるActivityStreamは以下のような形になる:

val activityCT = ContentType.WithFixedCharset(
  MediaType.applicationWithFixedCharset(
    "activity+json",
    HttpCharsets.`UTF-8`
  )
)

ContentTypeに適切なコンストラクタメソッドが生えているので、適宜使おう。

Acceptヘッダ

ちなみに類似の話題としてAcceptヘッダーがある。これはHTTPリクエストを送信する際に、「こういうレスポンス形式がいいです」とお伝えするもの。

Acceptヘッダを設定するには、HttpRequestを生成するときにheadersにAcceptを入れる:

import akka.http.scaladsl.model
HttpRequest(
  ...,
  headers = Seq(
    headers.Accept(MediaRange(foobarMediaType)),
  )
)

AcceptMediaRangeをコンストラクタ引数として取るのだが、実質1つしかAcceptしないようなときはMediaRangeMediaTypeを渡せばよい。

ChatGPTくんに聞いたら、こういうのを作ってくれた:

def accept(mediaType: MediaType): Directive1[Accept] =
  headerValueByType[Accept](()).flatMap { accept =>
    if (accept.mediaRanges.exists(_.matches(mediaType))) provide(accept)
    else reject
  }

これにより、以下のように書ける:

// クライアントがtext/htmlを望んでいるときにのみマッチする
accept(MediaTypes.`text/html`) { _ => complete("<html><body>response for html</body></html>") }
★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?