ChromeとSafariでアドレスバーの振る舞いに違う部分があるのにお気づきだろうか?それぞれのブラウザで、Wikipediaの日本語のページにアクセスし、アドレスバーのURLをコピーして、エディタに貼り付けてみると違いに気づく。実際に試した方のエディタはこのような結果になっているはずだ:
Chrome: http://ja.wikipedia.org/wiki/%E6%97%A5%E6%9C%AC%E8%AA%9E
Safari: http://ja.wikipedia.org/wiki/日本語
ご覧のようにChromeはパーセントエンコーディングしたURLになるが、一方のSafariはエンコーディングしていないURLになる。
最近のブラウザは賢く、URLがエンコードされていないものであっても、ちゃんと解釈して意図したページを開いてくれる。なので、もし、Safariユーザが投稿の本文に日本語がそのままのURLを貼り付けても、ブラウザのおかげで問題になることは少ない。
しかし、ユーザが入力したURLを元にそのページにアクセスしようとするプログラムで単純なものは、エンコードされたURLでなければ失敗してしまう恐れがある。開発者はURLエンコーディングされていなければ、正しくURLエンコードして、そういったプログラムに渡すようにしなければならない。
http://ja.wikipedia.org/wiki/文字
という入力を受け取ったら、http://ja.wikipedia.org/wiki/%E6%96%87%E5%AD%97
に変換するが、入力がhttp://ja.wikipedia.org/wiki/%E6%96%87%E5%AD%97
だったら何もしない。そのような関数が必要になる。
Scalaでこれを実現する関数を作ってみた。下に示したIdempotentURLEncoder
がその実装になる:
import java.net.URL
import scala.util.parsing.combinator.RegexParsers
object IdempotentURLEncoder extends RegexParsers {
override def skipWhitespace = false
private def segment = rep(char)
private def char = unreserved | escape | any ^^ { java.net.URLEncoder.encode(_, "UTF-8") }
private def unreserved = """[A-Za-z0-9._~!$&'()*+,;=:@-]""".r
private def escape = """%[A-Fa-f0-9]{2}""".r
private def any = """.""".r
private def encodeSegment(input: String): String = parseAll(segment, input).get.mkString
private def encodeSearch(input: String): String = encodeSegment(input)
def encode(url: String): String = {
val u = new URL(url)
val path = u.getPath.split("/", -1).map(encodeSegment).mkString("/")
val query = u.getQuery match {
case null => ""
case q: String => "?" + encodeSearch(q)
}
val hash = u.getRef match {
case null => ""
case h: String => "#" + encodeSegment(h)
}
s"${u.getProtocol}://${u.getAuthority}$path$query$hash"
}
}
このエンコーダーは冪等性がある。エンコードする必要があるときだけ文字を置換するので、一度エンコードした文字が再びエンコードされることがない。次のコードのurl1
とurl2
は同じ値になる。
import IdempotentURLEncoder._
val url1 = encode("http://ja.wikipedia.org/wiki/文字")
val url2 = encode(encode("http://ja.wikipedia.org/wiki/文字"))
他にも、エンコード後の文字とエンコード前の文字が混在しているURLでも、未エンコードの文字だけ変換するような振る舞いもあるが、詳しい振る舞いについてはテストコードで提示しておく↓
import org.scalatest.{ FunSuite, Matchers }
class IdempotentURLEncoderSpec extends FunSuite with Matchers {
import IdempotentURLEncoder._
test("Idempotent operation") {
val url = "http://ja.wikipedia.org/wiki/文字"
assert(encode(url) == encode(encode(url)))
assert(encode(url) == encode(encode(encode(url))))
}
test("Segment encoding") {
encode("http://ja.wikipedia.org/wiki/文字")
.shouldBe("http://ja.wikipedia.org/wiki/%E6%96%87%E5%AD%97")
}
test("Query string encoding") {
encode("http://qiita.com/search?utf8=✓&sort=rel&q=開発&sort=rel")
.shouldBe("http://qiita.com/search?utf8=%E2%9C%93&sort=rel&q=%E9%96%8B%E7%99%BA&sort=rel")
}
test("Hash encoding") {
encode("https://www.google.co.jp/#q=文字")
.shouldBe("https://www.google.co.jp/#q=%E6%96%87%E5%AD%97")
}
test("Partial encoding") {
encode("http://en.wiktionary.org/wiki/français")
.shouldBe("http://en.wiktionary.org/wiki/fran%C3%A7ais")
}
test("Space is encoded as +") {
encode("http://example.com/foo bar buz")
.shouldBe("http://example.com/foo+bar+buz")
}
test("Multibyte domain names are not supported yet :(") {
encode("http://日本語.jp")
.shouldBe("http://日本語.jp")
}
}
エンコーダの実装にはScalaのパーサコンビネータを使ったが、これについては以前書いた投稿『面倒くさいパーサの実装もDSLで書くだけ!そう、Scalaならね - Qiita』で触れたので、ここでは割愛する。