Test iOS JSON Pointer Resolution on a Cloud Mac
The server reports a validation error at /items/0/name, but the iOS client highlights the wrong field. The problem often lies not in UI bindings, but in a path resolver that drops empty keys, treats 01 as an array index, or conflates a missing field with a valid null. On a cloud Mac, you can establish these semantics in a standalone Swift script first, then move the fixtures into the app’s test target without running the entire UI flow.
1. Define the Read Contract First
This article implements the JSON string representation of JSON Pointer defined in RFC 6901: an empty string points to the entire document, and every nonempty path must start with /. It does not accept the URI fragment representation starting with #, modify data, or implement the JSONPath query language.
Splitting a path must preserve empty segments. The path / accesses an empty key, while /a/ accesses an empty key inside object a. Do not strip the trailing slash. Object member names are always compared as strings; a numeric token is interpreted as an array index only when the current node is actually an array.
An existing path whose value is null is a successful read. A nonexistent path is a failure. Do not represent both outcomes with the same optional value.
The user-facing error can ultimately be “Unable to locate field,” but tests should distinguish syntax errors, missing members, index errors, and node type errors. That distinction makes API contract changes easier to trace.
2. Implement Strict Escape Handling and Node Traversal
Save the following code as PointerCheck.swift. It depends only on Foundation, runs with the currently selected toolchain, and requires no additional packages. Escape decoding accepts only ~0 and ~1, which decode to ~ and /, respectively.
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
}
Do not replace ~0 first and then replace ~1: the original sequence ~01 must decode to the literal string ~1, not a slash. Scanning each token also rejects a lone tilde and ~2, rather than silently “repairing” invalid paths.
The array branch checks for ASCII digits, rejecting signs, whitespace, and other digit characters. A failed Int conversion is also an index error; do not force-unwrap it. In an object, however, "01" is a valid key and must not be subjected to array index rules.
3. Cover Ambiguous Edge Cases with Small Fixtures
Append the following acceptance checks. The fixture includes an empty key, keys with special characters, an array, scalar values, and null. Each assertion verifies a single outcome.
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")
}
Add another set of fixtures with an array or a scalar at the root. A root scalar must resolve successfully with an empty path, while an attempt to traverse further must fail. Compare strings as supplied, without automatic case folding or Unicode normalization, or you could read a different member.
4. Integrate with Cloud Builds, Not Just the Example
Run the following in an SSH session:
set -eu
xcrun --find swift
xcrun swift PointerCheck.swift
The script exits silently on success and returns a nonzero status if an assertion fails. Do not run this acceptance script with compiler optimizations that could remove the checks. When integrating it into the app, put the resolver in a shared module, convert the assertions into test-target assertions, and run them through the project’s existing test entry point.
The standalone script verifies read semantics on a Foundation data tree; it does not replace integration tests for the iOS target. If the app traverses custom decoded models directly, create the same fixtures for those models rather than assuming the two representations behave identically.
When input comes from an external API, also limit document size, path length, and segment count before invoking the resolver. Set thresholds according to the scale of the application’s fields, and test both the limits and values beyond them. The short paths in these examples do not define a resource-consumption boundary.
5. Inspect Failed Input Instead of Falling Back to Guesswork
When resolution fails, first record the error category, segment position, and current node type—not the entire application document. The path itself may also contain user data, so redact it before including it in build attachments.
Next, confirm whether the server supplies JSON Pointer or another path syntax, then check whether the path has undergone additional URL decoding. In the string representation supported here, a percent sign is just an ordinary character; do not apply an extra decoding pass without justification.
Finally, add every failing input you fix to a permanent fixture set. Acceptance should mean that valid paths read precisely and invalid paths fail explicitly, not that the resolver “tries to find a similar field.” This gives error messages, configuration reads, and field mapping a single, stable path contract.
Frequently asked questions
Are an empty pointer and a single slash equivalent?
No. An empty pointer returns the entire document. A single slash addresses the member with an empty key in the root object. Resolution must fail if that member does not exist.
Can an array index be 01 or a hyphen?
This read-only resolver rejects both. It accepts 0 or decimal integers without leading zeros. A hyphen does not identify a readable element; out-of-range indices and integer overflow also fail.
Test your cloud Mac with your own pipeline
ArmRDP provides dedicated physical Mac minis, not virtual machines. Rent by the day, verify your toolchain and build results, then choose a rental term that works for your team.
Rent a Cloud Mac