RFC 的には text/* のデフォルトエンコーディングは ISO-8859-1

以下のような、日本語のデータを text/csv という Content-Type で返す Play アプリケーションで、

GET     /test                 controllers.Application.test
package controllers

import play.api._
import play.api.mvc._
import play.api.libs.iteratee.Enumerator
import scala.concurrent.ExecutionContext.Implicits.global

object Application extends Controller {

  def test = Action {
    implicit request => {
      Result(
        header = ResponseHeader(200, Map(CONTENT_TYPE -> "text/csv", CONTENT_DISPOSITION -> ("attachment; filename=test.csv"))),
        body = Enumerator.outputStream { out =>
          try {
            // CSVデータを出力
            out.write("テスト".getBytes("UTF-8"))
          } finally {
            out.close()
          }
        }
      )
    }
  }

}

次のようなテストを書くとエラーになるんだけどなんで?と聞かれました。

import org.scalatest._
import play.api.test._
import play.api.test.Helpers._
import play.api.libs.ws.WS
import play.api.Play.current

class ApplicationSpec extends FlatSpec with Matchers {

  it should "work" in {
    running(TestServer(3333, FakeApplication())) {
      val testUrl = "http://localhost:3333/test"
      val body = await(WS.url(testUrl).get).body
      body should be("テスト")
    }
  }
}
[info] Compiling 1 Scala source to /Users/toshi/work/scala/play-hello/target/scala-2.11/test-classes...
[info] ApplicationSpec:
[info] - should works *** FAILED ***
[info]   "[ãã¹ã]" was not equal to "[テスト]" (ApplicationSpec.scala:13)
[info] ScalaTest
[info] Run completed in 3 seconds, 921 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 0, failed 1, canceled 0, ignored 0, pending 0
[info] *** 1 TEST FAILED ***
[error] Failed: Total 1, Failed 1, Errors 0, Passed 0
[error] Failed tests:
[error]         ApplicationSpec
[error] (test:test) sbt.TestsFailedException: Tests unsuccessful
[error] Total time: 7 s, completed 2014/10/18 23:44:56

見ての通り、エンコードがおかしくなってアサーションが通りません。

これ不思議だなあと思って調べたんですが、Play の WS(クライアント側) のソースコードの該当部分を見てみるとコメントがついていました。

https://github.com/playframework/playframework/blob/2.3.5/framework/src/play-ws/src/main/scala/play/api/libs/ws/ning/NingWS.scala#L681-L693

  lazy val body: String = {
    // RFC-2616#3.7.1 states that any text/* mime type should default to ISO-8859-1 charset if not
    // explicitly set, while Plays default encoding is UTF-8.  So, use UTF-8 if charset is not explicitly
    // set and content type is not text/*, otherwise default to ISO-8859-1
    val contentType = Option(ahcResponse.getContentType).getOrElse("application/octet-stream")
    val charset = Option(AsyncHttpProviderUtils.parseCharset(contentType)).getOrElse {
      if (contentType.startsWith("text/"))
        AsyncHttpProviderUtils.DEFAULT_CHARSET
      else
        "utf-8"
    }
    ahcResponse.getResponseBody(charset)
  }

Play のデフォルトのエンコーディングUTF-8 なのですが、 RFC-2616 的には Content-Type が text/* のデフォルトエンコーディングは ISO-8859-1 なので Content-Type が text/* なデータはあえて ISO-8859-1 にしてあります。

つまりサーバーの実装のほうが、

CONTENT_TYPE -> "text/csv; charset=utf-8;"

とちゃんと UTF-8 と書けって話でした。

なるほどなとは思いましたが、サーバー側のほうは Play のデフォルトである UTF-8 で返すのにクライアントだけ ISO-8859-1 で受け取るってのはハマるよなあと思いました。ただサーバーのほうでもエンコーディングが指定されていなければ ISO-8859-1 に変えるとするのは今の Play の実装では無理があるし、そんなに望まれている動作でもないかもなとも思います。微妙ですね。

なので、とりあえず RFC-2616 的には Content-Type が text/* のデフォルトエンコーディングは ISO-8859-1 という事実だけ覚えておくことにしました。

The "charset" parameter is used with some media types to define the character set (section 3.4) of the data. When no explicit charset parameter is provided by the sender, media subtypes of the "text" type are defined to have a default charset value of "ISO-8859-1" when received via HTTP. Data in character sets other than "ISO-8859-1" or its subsets MUST be labeled with an appropriate charset value. See section 3.4.1 for compatibility problems.