Проверка путей JSON Pointer для iOS на облачном Mac

Проверка путей JSON Pointer для iOS на облачном Mac

Проверка путей JSON Pointer для iOS на облачном Mac

Сервер указывает, что ошибка валидации находится по пути /items/0/name, а клиент iOS подсвечивает не то поле. Часто дело не в привязке интерфейса, а в том, что обработчик пути теряет пустые ключи, принимает 01 за индекс массива или не различает отсутствующее поле и допустимое значение null. На облачном Mac можно сначала закрепить эти правила отдельным скриптом Swift, а затем перенести примеры в тестовый таргет приложения — без запуска всего сценария в интерфейсе.

1. Сначала определите контракт чтения

В этой статье реализована строковая форма JSON Pointer по RFC 6901: пустая строка указывает на весь документ, а любой непустой путь должен начинаться с /. Реализация не принимает форму URI-фрагмента, начинающуюся с #, не изменяет данные и не является языком запросов JSONPath.

При разбиении пути необходимо сохранять пустые сегменты. Путь / обращается к пустому ключу, а /a/ — к пустому ключу внутри объекта a. Удалять завершающий слеш нельзя. Имена полей объекта всегда сравниваются как строки; числовой индекс интерпретируется только тогда, когда текущий узел действительно является массивом.

Если путь существует и его значение равно null, чтение успешно. Если путь не существует, это ошибка. Не представляйте оба результата одним optional-значением.

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

2. Реализуйте строгую обработку экранирования и обход узлов

Сохраните приведённый ниже код в файл PointerCheck.swift. Он зависит только от Foundation, запускается с текущим выбранным набором инструментов и не требует установки дополнительных пакетов. При разборе экранирования допустимы только ~0 и ~1: они преобразуются в ~ и / соответственно.

import Foundation

enum PointerError: Error {
    case syntax
    case missing
    case index
    case scalar
}

func decodeToken(_ token: Substring) throws -> String {
    var result = ""
    var cursor = token.startIndex

    while cursor < token.endIndex {
        let character = token[cursor]
        cursor = token.index(after: cursor)

        if character != "~" {
            result.append(character)
            continue
        }

        guard cursor < token.endIndex else {
            throw PointerError.syntax
        }

        switch token[cursor] {
        case "0": result.append("~")
        case "1": result.append("/")
        default: throw PointerError.syntax
        }
        cursor = token.index(after: cursor)
    }
    return result
}

func resolve(_ pointer: String, in document: Any) throws -> Any {
    if pointer.isEmpty {
        return document
    }
    guard pointer.first == "/" else {
        throw PointerError.syntax
    }

    let tokens = try pointer.dropFirst()
        .split(separator: "/", omittingEmptySubsequences: false)
        .map { try decodeToken($0) }

    var current = document

    for token in tokens {
        if let object = current as? [String: Any] {
            guard let value = object[token] else {
                throw PointerError.missing
            }
            current = value
        } else if let array = current as? [Any] {
            let digits = token.utf8
            guard !digits.isEmpty,
                  digits.allSatisfy({ $0 >= 48 && $0 <= 57 }),
                  token == "0" || !token.hasPrefix("0"),
                  let index = Int(token),
                  index < array.count else {
                throw PointerError.index
            }
            current = array[index]
        } else {
            throw PointerError.scalar
        }
    }
    return current
}

Нельзя сначала заменять ~0, а затем ~1: исходная последовательность ~01 должна превратиться в буквальную строку ~1, а не в слеш. Последовательный разбор каждого сегмента также позволяет отклонять одиночную тильду и ~2, а не молча «исправлять» некорректные пути.

В ветке обработки массивов проверяются именно цифры ASCII. Знаки плюса и минуса, пробельные символы и другие цифровые символы не допускаются. Неудачное преобразование в Int тоже считается ошибкой индекса; принудительное извлечение optional здесь недопустимо. При этом "01" — допустимое имя ключа в объекте, и применять к нему правила индексации массива нельзя.

3. Проверьте неоднозначные граничные случаи на небольших примерах

Добавьте ниже следующий проверочный код. В примере одновременно присутствуют пустой ключ, ключи со специальными символами, массив, скалярные значения и null. Каждая проверка подтверждает только один результат.

let source = #"{"a/b":{"~key":[null,7]},"":9,"~1":11,"01":12}"#
let document = try JSONSerialization.jsonObject(
    with: Data(source.utf8),
    options: [.fragmentsAllowed]
)

let nullValue = try resolve("/a~1b/~0key/0", in: document)
precondition(nullValue is NSNull)

let emptyKey = try resolve("/", in: document)
precondition((emptyKey as? NSNumber)?.intValue == 9)

let escapedKey = try resolve("/~01", in: document)
precondition((escapedKey as? NSNumber)?.intValue == 11)

let numericKey = try resolve("/01", in: document)
precondition((numericKey as? NSNumber)?.intValue == 12)

let root = try resolve("", in: document)
precondition(root is [String: Any])

let rejected = [
    "a", "#/a", "/bad~2", "/bad~",
    "/absent", "/a~1b/~0key/01",
    "/a~1b/~0key/-", "/a~1b/~0key/2",
    "/a~1b/~0key/999999999999999999999999999999",
    "/a~1b/~0key/1/child"
]

for pointer in rejected {
    var failed = false
    do {
        _ = try resolve(pointer, in: document)
    } catch is PointerError {
        failed = true
    }
    precondition(failed, "Unexpected resolution success")
}

Дополните набор примерами, в которых корень документа — массив или скаляр. Для корневого скаляра пустой путь должен успешно возвращать значение, а попытка пройти глубже должна завершаться ошибкой. Строки нужно сравнивать без изменений: не применять автоматически приведение регистра или нормализацию Unicode, иначе можно обратиться к другому полю.

4. Включите проверки в облачную сборку, а не ограничивайтесь примером

Выполните в SSH-сеансе:

set -eu
xcrun --find swift
xcrun swift PointerCheck.swift

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

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

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

5. При ошибке проверяйте входные данные, а не пытайтесь угадать путь

Если разрешить путь не удалось, сначала зафиксируйте категорию ошибки, номер сегмента и тип текущего узла, но не записывайте весь документ с данными приложения. Сам путь тоже может содержать пользовательские данные, поэтому прикладывать его к материалам сборки следует только после обезличивания.

Затем уточните, передаёт ли сервер JSON Pointer или путь в другом синтаксисе, и проверьте, не применялось ли к нему дополнительное URL-декодирование. В поддерживаемой здесь строковой форме знак процента — обычный символ; выполнять ещё один проход декодирования без оснований нельзя.

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

Часто задаваемые вопросы

Пустой путь и один символ косой черты означают одно и то же?

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

Можно ли использовать 01 или дефис как индекс массива?

Этот алгоритм чтения отклоняет оба варианта. Допустимы 0 и десятичные целые без ведущего нуля. Дефис не обозначает читаемый элемент; выход за границы и переполнение целого также считаются ошибками.

Проверьте облачный Mac на собственной сборке

ArmRDP предоставляет выделенный физический Mac mini, а не виртуальную машину. Арендуйте посуточно: сначала проверьте инструменты и результат сборки, затем выберите подходящий срок аренды для команды.

Арендовать облачный Mac

Проверяйте конфигурацию и воспроизводите сборки

Проверьте модель, срок аренды и порядок подключения, а затем перенесите задачи на выделенный физический узел.