必要があればURLエンコーディングしたい



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`がその実装になる:


```scala:IdempotentURLEncoder.scala
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`は同じ値になる。

```scala
import IdempotentURLEncoder._
val url1 = encode("http://ja.wikipedia.org/wiki/文字")
val url2 = encode(encode("http://ja.wikipedia.org/wiki/文字"))
```

他にも、エンコード後の文字とエンコード前の文字が混在しているURLでも、未エンコードの文字だけ変換するような振る舞いもあるが、詳しい振る舞いについてはテストコードで提示しておく↓

```scala:IdempotentURLEncoderSpec
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](http://qiita.com/suin/items/35bc4afe618cb77f80f6)』で触れたので、ここでは割愛する。




[Wikipediaの日本語のページ]: http://ja.wikipedia.org/wiki/%E6%97%A5%E6%9C%AC%E8%AA%9E