雲端 Mac 上驗證 iOS URL 查詢參數編碼與重複鍵契約

雲端 Mac 上驗證 iOS URL 查詢參數編碼與重複鍵契約

雲端 Mac 上驗證 iOS URL 查詢參數編碼與重複鍵契約

搜尋框輸入 C++ 入門,伺服器卻收到 C 入門;篩選條件傳了兩個同名鍵,最後只剩一個。這類問題通常不是網路遺失封包,而是用戶端編碼與伺服器解碼的約定不一致。在雲端 Mac 上將 URL 建構器拆成純函式,用 XCTest 鎖定邊界行為,比反覆擷取線上請求更容易定位問題,也不需要啟動模擬器。

先明確定義參數契約

查詢字串不是字典。介面必須先約定:同名鍵是否可以重複、順序是否有意義、缺少參數、無值參數與空字串是否等價,以及伺服器是否採用表單解碼規則。前端與後端各自「使用標準函式庫」,不代表雙方對這些問題有相同答案。

例如,flag、flag= 與完全沒有 flag 是三種不同的輸入。用戶端可以分別用 URLQueryItem 的 nil、空字串,以及不加入項目來表達;伺服器框架是否保留這些差異,必須用實際的解析器驗證。

一般 URL 查詢中的字面加號,不會自動等同於空格。將加號解讀為空格是表單編碼體系的規則,不能直接套用到所有查詢介面。

將這些約定寫進測試名稱。不要只保留一個「正常搜尋成功」的案例,否則即使中文測試通過,加號與重複篩選條件仍可能出錯。

用純函式建構 URL

建立一個 macOS 單元測試目標,加入下列 Swift 檔案。範例網址只用於本機解析,不會送出請求。整合到業務程式時,將固定網址換成專案中的介面設定;參數值必須傳入原始文字,不能先手動進行百分比編碼。

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)
    }
}

第一個測試檢查保留字元是否產生了額外的參數或片段;第二個測試確認陣列中的重複鍵與順序沒有遺失;第三個測試模擬一次表單解碼處理。它不是完整的伺服器解析器,只驗證單一值的加號邊界行為,不能取代介面整合測試。

擴充失敗案例,而不是擴大連網範圍

在同一個測試夾具中補上中文、組合字元、表情符號、空值、前後空格,以及字面字串 %2B。最後一項尤其重要:使用者輸入的百分號必須經過編碼,不能被視為已編碼的加號。

遇到 %252B 時,先確認輸入究竟是原始加號,還是字面字串 %2B。前者通常代表二次編碼,後者則可能正是正確結果。只因看到 %25 就判定有錯,可能讓修正反而引入新的問題。

不要用一個涵蓋範圍過大的字元集合,對已組好的整條 URL 進行編碼。參數名稱、參數值與路徑屬於不同的編碼情境;直接串接 &、=、# 可能改變結構。讓建構器負責結構,再分別檢查解析後的項目數量、名稱與值。

對超長搜尋詞另外設定輸入長度限制。編碼後的位元組數與字元數不同,應依照介面契約測試限制策略,而不是假設一個中文字只占一個位元組。

為簽章請求加入位元組層級驗收

語意相同的 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 版本、提交識別碼與建構器模式。同一批測試應使用一致的工具鏈;升級工具鏈後,要重新檢查簽章位元組,不能只看綠色的通過結果有多少。

驗收時確認四件事:原始值沒有預先編碼;重複鍵沒有被字典吃掉;表單解碼開關由介面契約決定;實際的伺服器解析器通過同一組測試資料。失敗報告只需保存輸入類別與編碼結果;涉及真實搜尋詞或權杖時,改用合成測試資料。

最後,讓純函式測試守住用戶端結構,讓伺服器契約測試守住解碼邊界。兩層都通過,才能證明參數已正確傳遞,而不只是兩次用戶端解析恰巧得到相同結果。

常見問題

URLComponents 一定會把查詢值的加號編碼成 %2B 嗎?

不一定。一般 URL 查詢可以保留加號,但表單解碼器可能將它當成空白。若介面明確使用表單解碼,應在 URLComponents 完成編碼後,把 percentEncodedQuery 中剩餘的字面加號替換成 %2B,並加入伺服器解碼測試。

查詢參數可以先轉成字典再參與簽章嗎?

只有協定明確禁止重複鍵、忽略順序,並定義正規化規則時才適合。否則字典會遺失重複項目或順序。應保留 URLQueryItem 陣列,並針對簽章要求的最終編碼位元組建立獨立測試。

為開發工作租用專屬雲端 Mac

採用 Apple Silicon 實體主機,並非虛擬機。提供日租、週租、月租及季租,機型與價格公開透明;實際可租狀態請以控制台即時資訊為準。

立即租用雲端 Mac 查看機型與租期