クラウド Mac で iOS URL クエリの符号化ルールを検証する
検索欄に C++ 入门 と入力したのに、サーバーには C 入门 と届く。同じキーでフィルター条件を二つ送ったのに、最後には一つしか残らない。こうした不具合は、ネットワークのパケット損失ではなく、クライアントのエンコードとサーバーのデコードの取り決めが食い違っていることが原因になりがちです。クラウド Mac 上で URL の構築処理を純粋関数に切り出し、境界条件を XCTest でテストケースとして固定すれば、本番リクエストを何度もキャプチャするより原因を絞りやすくなります。シミュレータを起動する必要もありません。
まずパラメータの契約を明文化する
クエリ文字列は辞書ではありません。API では、同じキーを繰り返してよいか、順序に意味があるか、パラメータの欠落・値なし・空文字列を同等に扱うか、サーバーがフォーム形式のデコード規則を使うかを、あらかじめ決める必要があります。フロントエンドとバックエンドがそれぞれ「標準ライブラリを使っている」だけでは、これらの扱いが一致するとは限りません。
たとえば、flag、flag=、flag 自体がない状態は、異なる三つの入力です。クライアントでは、URLQueryItem の値を nil にする、空文字列にする、項目を追加しない、という方法でそれぞれ表現できます。サーバー側のフレームワークがこの違いを保持するかどうかは、実際のパーサーで確かめる必要があります。
通常の URL クエリでは、リテラルのプラス記号が当然に空白を意味するわけではありません。プラス記号を空白として解釈するのはフォームエンコードの規則であり、すべてのクエリ API にそのまま当てはめることはできません。
こうした取り決めをテスト名に反映しましょう。「通常の検索が成功する」というケースだけを残しても不十分です。中国語の検索語は通っても、プラス記号や重複するフィルター条件では不具合が残る可能性があります。
純粋関数で URL を構築する
macOS のユニットテストターゲットを作成し、次の Swift ファイルを追加します。例の URL はローカルでの解析にのみ使い、リクエストは送信しません。実際のアプリに組み込む際は、固定の URL をプロジェクトの API 設定に置き換えてください。パラメータ値は元の文字列のまま渡し、事前に手動でパーセントエンコードしてはいけません。
import Foundation
import XCTest
func makeQueryURL(
items: [URLQueryItem],
formDecoder: Bool = false
) throws -> URL {
var parts = URLComponents()
parts.scheme = "https"
parts.host = "xcodenode.com"
parts.path = "/query-fixture"
parts.queryItems = items
if formDecoder, let encoded = parts.percentEncodedQuery {
parts.percentEncodedQuery = encoded.replacingOccurrences(
of: "+",
with: "%2B"
)
}
return try XCTUnwrap(parts.url)
}
final class QueryEncodingTests: XCTestCase {
func testReservedCharactersRoundTrip() throws {
let value = "C++ 入门 & x=1 # 50% / ?"
let url = try makeQueryURL(items: [
URLQueryItem(name: "q", value: value)
])
let parts = try XCTUnwrap(
URLComponents(url: url, resolvingAgainstBaseURL: false)
)
let items = try XCTUnwrap(parts.queryItems)
XCTAssertEqual(items.count, 1)
XCTAssertEqual(items.first?.value, value)
XCTAssertNil(parts.fragment)
}
func testDuplicatesAndEmptyValues() throws {
let url = try makeQueryURL(items: [
URLQueryItem(name: "tag", value: "swift"),
URLQueryItem(name: "tag", value: "ios"),
URLQueryItem(name: "flag", value: nil),
URLQueryItem(name: "empty", value: "")
])
let parts = try XCTUnwrap(
URLComponents(url: url, resolvingAgainstBaseURL: false)
)
let items = try XCTUnwrap(parts.queryItems)
XCTAssertEqual(items.map(\.name), [
"tag", "tag", "flag", "empty"
])
XCTAssertEqual(items.map(\.value), [
"swift", "ios", nil, ""
])
}
func testFormDecoderReceivesLiteralPlus() throws {
let value = "C++ 50%"
let url = try makeQueryURL(
items: [URLQueryItem(name: "q", value: value)],
formDecoder: true
)
let parts = try XCTUnwrap(
URLComponents(url: url, resolvingAgainstBaseURL: false)
)
let query = try XCTUnwrap(parts.percentEncodedQuery)
XCTAssertFalse(query.contains("+"))
let pair = try XCTUnwrap(
query.split(separator: "=", maxSplits: 1).last
)
let decoded = String(pair)
.replacingOccurrences(of: "+", with: " ")
.removingPercentEncoding
XCTAssertEqual(decoded, value)
}
}
最初のテストでは、予約文字によって余計なパラメータやフラグメントが生成されていないことを確認します。二つ目では、配列内の重複キーと順序が失われていないことを確認します。三つ目は、フォーム形式のデコードを一回行う処理を模したものです。完全なサーバー側パーサーではなく、単一の値に含まれるプラス記号の境界条件だけを検証するため、API の結合テストの代わりにはなりません。
ネットワーク接続を増やすのではなく、失敗ケースを増やす
同じテストフィクスチャに、中国語、結合文字、絵文字、空値、前後の空白、リテラル文字列 %2B を追加します。特に最後のケースは重要です。ユーザーが入力したパーセント記号はエンコードする必要があり、すでにエンコードされたプラス記号として扱ってはいけません。
%252B が現れたら、入力が元のプラス記号なのか、リテラルの %2B なのかを先に確認してください。前者なら通常は二重エンコードを示しますが、後者なら正しい結果である可能性があります。%25 が見えたという理由だけで不正と判断すると、修正によって新たな不具合を生みかねません。
幅広い文字を許可する単一の文字セットで、組み立て済みの URL 全体をエンコードしてはいけません。パラメータ名、パラメータ値、パスでは、それぞれ文脈が異なります。&、=、# を直接連結すると、URL の構造が変わることがあります。構造の組み立ては URL 構築関数に任せ、解析後の項目数、名前、値をそれぞれ確認しましょう。
非常に長い検索語には、別途入力長の制限を設けます。エンコード後のバイト数と文字数は異なるため、中国語の一文字を一バイトとみなすのではなく、API の契約に沿って制限方針をテストしてください。
署名付きリクエストはバイト単位でも検証する
意味が同じ URL でも、バイト列が同じとは限りません。パラメータの順序、空白の表現方法、パーセントエスケープの大文字・小文字は、いずれも署名に影響する可能性があります。通常のクエリテストでは、エンコードとデコードを経た値の一致を確認できますが、署名のテストでは、プロトコルが定める正規化文字列も検証しなければなりません。
プロトコルがソートを要求するなら、まず重複キーの並べ方を明確にし、その後で最終文字列に署名します。順序の維持が必要なら、項目の配列を保持し、辞書を経由して組み直してはいけません。署名後にプラス記号の処理モードを切り替えると、送信内容と署名入力が食い違うため、切り替えないでください。
固定のテストデータには、同名キー、空値、非 ASCII テキストを含め、最終的なエンコード済み文字列をアサートします。ここでの「固定」は、プロジェクトで取り決めた形式を指します。ある時点のシステム出力を、無条件に永続的な基準とすることではありません。サーバー側にも同じテストデータを保存し、双方の署名入力が一致することを確認します。
クラウドノードで実行し、検証結果を保存する
テストターゲットは Foundation を使うだけでよく、アプリの署名設定は不要です。テストターゲットの共有 Scheme で実行します。以下の名前はプロジェクトの実際の名前に置き換え、結果の保存先にはまだ存在しないディレクトリを指定してください。
set -euo pipefail
mkdir -p artifacts
result="artifacts/query-$(uuidgen).xcresult"
xcodebuild test \
-project QueryContract.xcodeproj \
-scheme QueryContract \
-destination 'platform=macOS' \
-resultBundlePath "$result"
実行前に、Xcode のバージョン、コミット識別子、URL 構築関数のモードを記録します。同じ一連のテストではツールチェーンを揃え、ツールチェーンを更新した後は署名対象のバイト列を再確認してください。成功したテストの数を見るだけでは不十分です。
受け入れ時には、元の値が事前にエンコードされていないこと、重複キーが辞書によって失われていないこと、フォームデコードの切り替えが API の契約に基づいていること、実際のサーバー側パーサーが同じテストデータをすべて通過することの四点を確認します。失敗レポートには入力の分類とエンコード結果を保存すれば十分です。実際の検索語やトークンが含まれる場合は、合成したテストデータに置き換えてください。
最終的には、純粋関数のテストでクライアント側の構造を守り、サーバー側の契約テストでデコードの境界を守ります。両方が通過して初めて、パラメータが正しく渡されたと判断できます。クライアント側で二度解析した結果が、たまたま一致しただけでは不十分です。
よくある質問
URLComponents はプラス記号を必ず %2B に変換しますか?
必ず変換するとは限りません。通常の URL クエリではプラス記号をそのまま扱えますが、フォーム復号では空白になります。フォーム復号を使う契約なら、符号化後の percentEncodedQuery に残るプラス記号を %2B に置き換え、サーバー側の復号もテストします。
署名前にクエリを辞書へ変換してもよいですか?
重複キーを禁止し、順序を無視し、正規化手順が定義されている場合に限ります。それ以外は URLQueryItem の配列を保持し、署名に使う最終的な符号化バイト列を別のテストで確認します。
開発作業に最適な専有クラウドMacを選ぶ
Apple Silicon搭載の物理ノードを、仮想マシンではなく専有でご利用いただけます。日・週・月・四半期単位でレンタルでき、機種と料金は公開しています。実際の空き状況はコンソールにリアルタイムで表示されます。