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でのおおまかな処理の流れは、
- Directiveでリクエストにマッチさせる
- completeなどで
HttpResponse
を返す
というもの。ただし、例えばcomplete
といったよく使われるdirectiveはオーバロードされており、直接値を押し付けると文字列にシリアライズされて200 OKを返すような挙動になる。
前提2: Circeを使ったJSONハンドリング
今回はJSONのレスポンスにカスタムしたContent-Typeを設定したいという話なので、ちょっと補足。
akka-http-jsonというライブラリ群がある。その名の通り、Akka HTTPに様々なJSONライブラリを適合させるもので、簡単にリクエストボディやレスポンスボディにJSONを使えるようになる。
今回は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
のような単数の名前でモデル自体の定義が行われていることが殆んどなので、覚えておくとよい。同様の構造はHttpCharset
やHeader
でもあらわれる。
Content-Typeの構造
せっかくなので説明しておくと、Akka HTTPのContentType
は、2つのパートで構成されている:
MediaType
Binary
、WithFixedCharset
、WithOpenCharset
の3種類からなる
HttpCharset
- UTF-8とか。他にはUTF-16などもある
application/json
の部分がMediaType
で、charset=utf-8
の部分がHttpCharset
だといったら理解できるはず。ちなみにバイナリのMediaTypeも存在するので、HttpCharset
はContentType
の内部では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)),
)
)
Accept
はMediaRange
をコンストラクタ引数として取るのだが、実質1つしかAcceptしないようなときはMediaRange
にMediaType
を渡せばよい。
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>") }