Skip to main content

Tree Model and RFC 6901 JSON-Pointer

The MarshalledNode class is a typed tree façade layered over the generic-collections model — MarshalledMap / MarshalledList and their JsonMap / JsonList subclasses.

It gives developers coming from Jackson the ergonomics they expect — node-type introspection, typed value accessors, fluent tree building, and RFC 6901 JSON-Pointer addressing — without introducing a parallel Jackson-style node hierarchy.

important

MarshalledNode is a live view, not a copy and not a new node type. A node wraps a single backing value that is one of:

  • a Map (an object node),
  • a List (an array node),
  • a scalar (String, Number, Boolean, …) (a value node), or
  • null (a null node).

The underlying representation is still plain MarshalledMap/MarshalledList (or any Map/List). There is no ObjectNode / ArrayNode / TextNode class tree to learn — MarshalledNode is the single façade type for every node kind.

Creating nodes​

Use the static factories to wrap an existing value or to start a fresh tree:

// Wrap an existing value (stored as-is, no copy).
MarshalledNode node = MarshalledNode.of(myJsonMap);

// Start fresh containers (backed by a new empty JsonMap / JsonList).
MarshalledNode obj = MarshalledNode.objectNode();
MarshalledNode arr = MarshalledNode.arrayNode();

You can also obtain a node directly from the collections classes via the convenience MarshalledMap.at(String) and MarshalledList.at(String) methods, which resolve an RFC 6901 pointer and return a MarshalledNode:

JsonMap doc = Json.to("{foo:{bar:['x','y']}}", JsonMap.class);

// Returns a MarshalledNode wrapping "y".
MarshalledNode n = doc.at("/foo/bar/1");

Node-type introspection​

Four predicates report the kind of value a node wraps:

isObject() — wraps a Map.isArray() — wraps a List.isValue() — wraps a non-null scalar.isNull() — wraps null.

Typed accessors​

Value nodes can be read in a target type. The scalar accessors return null when the node is not a value node or the value cannot be converted (rather than throwing):

String  s = node.get("name").asString();
Integer i = node.get("age").asInt();
Long l = node.get("id").asLong();
Double d = node.get("score").asDouble();
Boolean b = node.get("active").asBoolean();

For richer conversions — including converting an object node straight to a bean — use as(Class), which routes through the same conversion machinery as MarshalledMap.get(String,Class). Use value() to get the raw backing object:

// Convert an object node to a bean.
Address addr = node.get("address").as(Address.class);

// Get the raw backing value.
Object raw = node.value();

Navigate into children by key (object nodes) or index (array nodes). Both get(...) forms return null on a miss (absent key, out-of-range index, or wrong node kind), and size() returns the entry/element count (0 for value and null nodes):

MarshalledNode child = node.get("address");   // by key
MarshalledNode first = node.get("phones").get(0); // by index
int count = node.get("phones").size();

Fluent tree building​

The put(String,Object) (object nodes) and add(Object) (array nodes) builders return this for chaining. They mutate the backing container in place, so callers still holding the underlying map/list see the change.

Example
// Build a tree fluently (mutates a backing JsonMap/JsonList in place).
MarshalledNode node = MarshalledNode.objectNode()
.put("a", 1)
.put("b", MarshalledNode.arrayNode().add("x").add("y").value());

String json = Json.of(node.value()); // {"a":1,"b":["x","y"]}

// Navigate and read typed values.
Integer a = node.get("a").asInt(); // 1
String x = node.get("b").get(0).asString(); // "x"

put(...) on a non-object node and add(...) on a non-array node both throw IllegalStateException.

Live view vs. copy() snapshot​

Because a node is a live view, building or mutating through it changes the shared backing container:

JsonMap backing = new JsonMap();
MarshalledNode node = MarshalledNode.of(backing);
node.put("x", 1);
// backing now contains {"x":1} — the caller's map saw the mutation.

When you need an independent snapshot, use copy(), which returns a new node backed by a recursive deep copy (maps into new JsonMaps, lists into new JsonLists; scalars and null are returned as-is). Mutating the copy never affects the original and vice versa:

MarshalledNode snapshot = node.copy();
snapshot.put("x", 999); // does not affect the original 'node'.

RFC 6901 JSON-Pointer addressing​

MarshalledNode exposes the full RFC 6901 pointer surface relative to its backing value:

at(String) — read; returns the addressed node or null on a read-miss.find(String) — read returning an Optional<MarshalledNode> (distinguishes a present null value from an absent one).set(String,Object) — auto-vivifying write; the - token appends to a list. The root pointer is rejected.remove(String) — removes the addressed member/element and returns it (null if absent).
JsonMap doc = Json.to("{foo:{bar:['x','y']}}", JsonMap.class);
MarshalledNode root = MarshalledNode.of(doc);

// Read.
String y = root.at("/foo/bar/1").asString(); // "y"

// Optional read — present even when the value is JSON null.
Optional<MarshalledNode> maybe = root.find("/foo/baz"); // Optional.empty()

// Auto-vivifying write — intermediate containers are created as needed.
root.set("/foo/qux/0", "new"); // creates {foo:{qux:["new"]}} alongside bar

// Append to a list with the '-' token.
root.set("/foo/bar/-", "z"); // bar is now ['x','y','z']

// Remove.
Object removed = root.remove("/foo/bar/0"); // returns "x"

A read-miss (missing key, out-of-range index, or type mismatch) resolves to null from at(...). The empty pointer "" addresses the whole backing document; on a node, set("", …) is rejected with IllegalArgumentException because the backing value cannot be replaced in place — set against a non-empty pointer instead.

The JsonPointer helper​

For pointer operations against an arbitrary Map/List root (not just through a node), use the standalone JsonPointer class directly. A pointer is a string of zero or more /-prefixed reference tokens; the empty string "" references the whole document (the root), and a non-empty pointer must begin with /.

JsonMap doc = JsonMap.of("foo", JsonList.of("bar", "baz"));

// Read (read-miss resolves to null).
Object v = JsonPointer.of("/foo/0").eval(doc); // "bar"

// Auto-vivifying write; '-' appends. Returns the (possibly new) effective root.
JsonPointer.of("/foo/-").set(doc, "qux"); // appends "qux"

// Remove returns the removed value (null if absent).
Object removed = JsonPointer.of("/foo/0").remove(doc); // "bar"

Token escaping (~0 / ~1) and the - append token​

Within a reference token, / is escaped as ~1 and ~ is escaped as ~0 (decode order matters: ~1→/ first, then ~0→~). Use encodeToken(String) and decodeToken(String) to convert between raw member names and escaped tokens:

// A member name literally named "a/b~c".
JsonPointer.encodeToken("a/b~c"); // "a~1b~0c"
JsonPointer.decodeToken("a~1b~0c"); // "a/b~c"

// Addressing that member:
JsonMap doc = Json.to("{'a/b~c':42}", JsonMap.class);
Object v = JsonPointer.of("/a~1b~0c").eval(doc); // 42

On a write, the special - token references "the element after the last element" of an array — i.e. it appends:

JsonMap doc = Json.to("{items:[1,2]}", JsonMap.class);
JsonPointer.of("/items/-").set(doc, 3); // items is now [1,2,3]

toString() round-trips a parsed pointer back to its canonical escaped string form. Array index tokens are strict base-10 non-negative integers with no leading zeros (0 itself is allowed); anything else is treated as an object member name.

note

MarshalledNode and JsonPointer are part of the next-generation typed tree model layered over the Marshalled* collections and are marked Beta — API subject to change. The underlying MarshalledMap/MarshalledList path-navigation methods (getAt/putAt/postAt/deleteAt) remain the stable, long-standing way to address nodes by slash-delimited path.