[Swift] URLエンコードのエスケープ範囲とその実装方法

はじめに

モバイルアプリサービス部の中安です。

最近、URLエンコードをする処理を書いたのですが、 その際に「URLエンコードって、どこまでエスケープすればよかったんだっけ」となったので、ちょっと調べごとしてみました。

後々の自分への備忘録の意味もこめて、ブログとして残したいと思います。

URLエンコード

URLエンコードとは

まず、基本的なお話。URLエンコードについてです。

URL/URIのファイル名やクエリ文字列などの一部としては使用できない記号や文字を、使用できる文字の特殊な組み合わせによって表記する変換規則。

表記できない文字の文字コードを16進数で表したものを「%」に続けて表記し、その文字を置き換える。例えば、半角シャープ「#」は「%23」、半角アスタリスク「*」は「%2A」と表記される。

(IT用語辞典)

このように、% に続けてエスケープするので「パーセントエンコーディング(percent-encoding)」とも呼ばれます。

RFC3986

さて、「どこまでエスケープすればよいか」についてですが、こちらは標準化された URI一般構文(Uniform Resource Identifier (URI): Generic Syntax) RFC3986 にて提唱されています。

予約文字

RFC3986 の 2章 にてURIの文字について言及されています。その中に「予約文字と非予約文字」という概念の話が書かれています。

2.2. Reserved Characters
2.2. 予約文字

URIs include components and subcomponents that are delimited by characters in the "reserved" set.
URIには「予約済み」の文字群によって区切られたコンポーネントとサブコンポーネントが含まれています。

These characters are called "reserved" because they may (or may not) be defined as delimiters by the generic syntax, by each scheme-specific syntax, or by the implementation-specific syntax of a URI's dereferencing algorithm.
これらの文字が「予約済み」と呼ばれるのは、一般的な構文や各スキーム特有の構文、あるいはURI逆参照アルゴリズム実装特有の構文の中において区切り文字として定義される(されない可能性もある)からです。

If data for a URI component would conflict with a reserved character's purpose as a delimiter, then the conflicting data must be percent-encoded before the URI is formed.
URIのコンポーネント用のデータが区切り文字としての目的を持つ予約文字と競合する場合、競合するデータはURIを生成する前にパーセントエンコードしなければなりません

つまりは、「予約文字」とされているものはパーセントエンコード(URLエンコード)すべしということです。 その予約文字とは下記のように「一般デリミタ(gen-delims)」と「サブデリミタ(sub-delims)」の複合であると定義されています。

reserved = gen-delims / sub-delims
gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@"
sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="

非予約文字

2.3. Unreserved Characters
2.3. 非予約文字

Characters that are allowed in a URI but do not have a reserved purpose are called unreserved.
URI内で許可されるが予約の目的を有しない文字は非予約と呼ばれます。

These include uppercase and lowercase letters, decimal digits, hyphen, period, underscore, and tilde.
これらには大文字と小文字の英字、数字、ハイフン、ピリオド、アンダースコア、そしてチルダが含まれます。

For consistency, percent-encoded octets in the ranges of ALPHA (%41-%5A and %61-%7A), DIGIT (%30-%39), hyphen (%2D), period (%2E), underscore (%5F), or tilde (%7E) should not be created by URI producers and, when found in a URI, should be decoded to their corresponding unreserved characters by URI normalizers.
整合性を保つため、大文字と小文字の英字(%41-%5A と %61-%7A)、数字(%30-%39)、ハイフン (%2D)、ピリオド(%2E)、アンダースコア (%5F)、そしてチルダ(%7E)の範囲で、パーセントエンコードされたオクテットを URIを生成する者は作るべきではなく、URIの中で見つけたときは、URI正規化によって対応する非予約文字にデコードすべきです。

このように「非予約文字」とされているものはパーセントエンコード(URLエンコード)はすべきでないということです。 その非予約文字とは既に上の文章で具体的に書かれている文字を指します。

unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"

では、「どこまでエスケープすればよいか」の解としては、非予約文字に含まれない文字すべてになるのでしょうか。

クエリで使用する場合の例外

RFC3986 の 3章 「構文構成」にて、以下のような言及があります。

3.4. Query
3.4. クエリ

The characters slash ("/") and question mark ("?") may represent data within the query component.
スラッシュ("/")とクエスチョンマーク("?")はクエリコンポーネントの中のデータを表すこともあります。

Beware that some older, erroneous implementations may not handle such data correctly when it is used as the base URI for relative references (Section 5.1), apparently because they fail to distinguish query data from path data when looking for hierarchical separators.
古い、またはエラーのある実装において相対参照(5.1章)用のベースURIとして使用すると、階層的な分離を見ようとする時にパスデータとクエリデータの区別に失敗し、正しくデータをハンドルできなくなる可能性を注意しなければなりません。

However, as query components are often used to carry identifying information in the form of "key=value" pairs and one frequently used value is a reference to another URI, it is sometimes better for usability to avoid percent-encoding those characters.
しかしながら、クエリコンポーネントは時に「キー=値」対形式で識別用の情報を渡すために使われますし、他のURIを参照する値としても使うことも多いですので、使いやすさのためにはこれらの文字のパーセントエンコードを避けたほうがよいでしょう。

要約すると「スラッシュとクエスチョンマークはパーセントエンコードを避けたほうがよい」ということになります。実際に iOSアプリ開発ではおなじみのネットワーキングライブラリ Alamofire ではこの2文字に関してはエスケープをしていない動作であるようです。

ということで

URLエンコードは、半角英数字とハイフン、ピリオド、アンダースコア、チルダ、スラッシュ、クエスチョンマーク以外の文字をエスケープするのがベターであるようです。(スラッシュ、クエスチョンマークに関しては上記の文章の注意が必要ですね)

Swiftでの実装

ここまでの話に基づいてSwiftでURLエンコードをするには、たとえば String の extension でこのように実装してみるのはいかがでしょうか。

extension String {
    
    var urlEncoded: String {
        // 半角英数字 + "/?-._~" のキャラクタセットを定義
        let charset = CharacterSet.alphanumerics.union(.init(charactersIn: "/?-._~"))
        // 一度すべてのパーセントエンコードを除去(URLデコード)
        let removed = removingPercentEncoding ?? self
        // あらためてパーセントエンコードして返す
        return removed.addingPercentEncoding(withAllowedCharacters: charset) ?? removed
    }
}

実際に試してみます。

// すべてエスケープされるはず
let escape = "あいうえお:#[]@!$&'()*+,;="
print(escape.urlEncoded)
// %E3%81%82%E3%81%84%E3%81%86%E3%81%88%E3%81%8A%3A%23%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D
// すべてエスケープされないはず
let noescape = "abcABC1234/?-._~"
print(noescape.urlEncoded)
// abcABC1234/?-._~

期待通りです。次は実践的にURLで試してみます。

下記のリンクはGoogle画像検索で「サッカー日本代表」をいくつか条件を入れてできあがったURLです。
サッカー日本代表

let url = "https://www.google.co.jp/search?q=%E3%82%B5%E3%83%83%E3%82%AB%E3%83%BC%E6%97%A5%E6%9C%AC%E4%BB%A3%E8%A1%A8&tbs=ic:color,itp:face,isz:lt,islt:svga&tbm=isch&source=lnt&sa=X&ved=0ahUKEwi23bCG_4rfAhVX6bwKHfMFBqwQpwUIHw&biw=1920&bih=929&dpr=1"
print(url.urlEncoded)
// https%3A//www.google.co.jp/search?q%3D%E3%82%B5%E3%83%83%E3%82%AB%E3%83%BC%E6%97%A5%E6%9C%AC%E4%BB%A3%E8%A1%A8%26tbs%3Dic%3Acolor%2Citp%3Aface%2Cisz%3Alt%2Cislt%3Asvga%26tbm%3Disch%26source%3Dlnt%26sa%3DX%26ved%3D0ahUKEwi23bCG_4rfAhVX6bwKHfMFBqwQpwUIHw%26biw%3D1920%26bih%3D929%26dpr%3D1

結果は非常に長いですが '":"' が '"%3A"'、'"="' が '"%3D"' に変わっていることが確認でき、文字列全体がクエリパラメータとして使用できることが確認できます。