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つのパートで構成されている:
MediaTypeBinary、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>") }