Как проверить кодирование URL-параметров iOS на облачном Mac

Как проверить кодирование URL-параметров iOS на облачном Mac

Как проверить кодирование URL-параметров iOS на облачном Mac

В поле поиска вводят C++ 入门, а сервер получает C 入门; в фильтре передают два параметра с одним именем, но в итоге остаётся только один. Причина таких сбоев обычно не в потере сетевых пакетов, а в несовпадении правил кодирования на клиенте и декодирования на сервере. Если на облачном Mac выделить построение URL в чистую функцию и закрепить граничные случаи тестами XCTest, найти причину будет проще, чем раз за разом перехватывать запросы в рабочей среде. При этом запускать симулятор не нужно.

Сначала определите контракт параметров

Строка запроса — не словарь. В контракте API нужно заранее определить, допустимы ли повторяющиеся имена параметров, важен ли их порядок, равнозначны ли отсутствующий параметр, параметр без значения и пустая строка, а также применяет ли сервер правила декодирования форм. Даже если и клиент, и сервер «используют стандартную библиотеку», ответы на эти вопросы могут различаться.

Например, flag, flag= и полное отсутствие flag — три разных варианта входных данных. На клиенте их можно выразить через URLQueryItem: передать nil, пустую строку или вовсе не добавлять элемент. Сохраняет ли серверный фреймворк эти различия, нужно проверять на реальном парсере.

В обычной строке запроса URL буквальный плюс сам по себе не означает пробел. Замена плюса на пробел — правило кодирования форм; его нельзя автоматически распространять на все API с параметрами запроса.

Отразите эти договорённости в названиях тестов. Не ограничивайтесь одним сценарием «обычный поиск проходит»: китайский текст может обрабатываться правильно, а плюсы и повторяющиеся фильтры — по-прежнему теряться.

Стройте URL с помощью чистой функции

Создайте цель модульного тестирования для macOS и добавьте в неё следующий Swift-файл. Адрес в примере используется только для локального разбора 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)
    }
}

Первый тест проверяет, что зарезервированные символы не создали лишних параметров или фрагмента URL. Второй подтверждает, что повторяющиеся имена и порядок элементов массива сохранились. Третий моделирует один проход декодирования по правилам форм. Это не полноценный серверный парсер: тест проверяет только обработку плюса в одном значении и не заменяет интеграционные тесты API.

Добавляйте проблемные примеры, а не сетевые запросы

Дополните тот же набор тестовых данных китайским текстом, комбинируемыми символами, эмодзи, пустыми значениями, пробелами в начале и конце, а также буквальной строкой %2B. Последний случай особенно важен: знак процента в пользовательском вводе должен кодироваться, а не восприниматься как часть уже закодированного плюса.

Если в результате появляется %252B, сначала выясните, что было на входе: обычный плюс или буквальная строка %2B. В первом случае это обычно указывает на двойное кодирование, а во втором результат вполне может быть правильным. Если считать любую последовательность %25 ошибкой, исправление способно породить новый сбой.

Не кодируйте весь уже собранный URL одним общим набором допустимых символов. Имена параметров, их значения и путь относятся к разным контекстам; простая конкатенация &, = и # может изменить структуру URL. Поручите формирование структуры конструктору, а затем отдельно проверьте количество, имена и значения параметров после разбора.

Для слишком длинных поисковых запросов задайте отдельное ограничение на длину входных данных. Число байтов после кодирования не равно числу символов. Проверяйте ограничения согласно контракту API, а не исходя из предположения, что один китайский символ занимает один байт.

Для подписанных запросов проверяйте точное представление в байтах

Семантически одинаковые URL не обязательно совпадают побайтово. На подпись могут влиять порядок параметров, способ представления пробелов и регистр букв в процентных последовательностях. Для обычного запроса достаточно проверить, что после кодирования и декодирования значение сохранилось; для подписанного запроса нужно также проверить каноническую строку, предусмотренную протоколом.

Если протокол требует сортировки, сначала определите, как сортировать параметры с одинаковыми именами, и только затем подписывайте итоговую строку. Если порядок должен сохраняться, оставьте параметры в массиве — не восстанавливайте их из словаря. После вычисления подписи не переключайте режим обработки плюсов, иначе передаваемые данные перестанут совпадать с входными данными для подписи.

Фиксированные примеры должны содержать повторяющиеся имена параметров, пустые значения и текст вне ASCII; в тестах нужно проверять итоговую закодированную строку. «Фиксированные» здесь означает соответствие формату, принятому в проекте, а не безусловное признание однажды полученного системного вывода вечным стандартом. На сервере следует сохранить те же примеры и убедиться, что обе стороны используют одинаковые входные данные для подписи.

Запускайте тесты на облачном узле и сохраняйте результаты

Для тестовой цели достаточно Foundation; настраивать подпись приложения не требуется. Запускайте тесты через общую схему (Shared 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, не виртуальные машины. Аренда на день, неделю, месяц или квартал. Конфигурации и цены опубликованы; актуальная доступность отображается в консоли.

Арендовать облачный Mac Конфигурации и сроки аренды