Skip to content

Commit 1f68ebd

Browse files
authored
Issue: implement RFC 6901 JSON Pointer as json-java21-jsonpointer module
Add a new Maven module json-java21-jsonpointer that implements RFC 6901 JSON Pointer. It depends only on the sibling json-java21 library. Public API: JsonPointer.parse(String) – validates syntax, returns JsonPointer JsonPointer.resolve(JsonValue) – resolves or throws JsonPointerResolutionException JsonPointer.exists(JsonValue) – returns boolean JsonPointer.tokens() – decoded reference token list JsonPointer.toString() – canonical pointer string (round-trips) JsonPointerSyntaxException – bad pointer syntax JsonPointerResolutionException – pointer cannot be resolved against a document All 12 RFC 6901 §5 example table entries pass. 51 unit tests pass. Compiler configured with -Xlint:all -Werror; zero warnings. To verify: mvn -pl json-java21-jsonpointer -am -Djava.util.logging.ConsoleHandler.level=INFO test
1 parent 49c6f51 commit 1f68ebd

7 files changed

Lines changed: 845 additions & 0 deletions

File tree

json-java21-jsonpointer/pom.xml

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
5+
http://maven.apache.org/xsd/maven-4.0.0.xsd">
6+
<modelVersion>4.0.0</modelVersion>
7+
8+
<parent>
9+
<groupId>io.github.simbo1905.json</groupId>
10+
<artifactId>parent</artifactId>
11+
<version>0.1.9</version>
12+
</parent>
13+
14+
<artifactId>java.util.json.jsonpointer</artifactId>
15+
<packaging>jar</packaging>
16+
<name>java.util.json Java21 Backport JSON Pointer</name>
17+
<url>https://simbo1905.github.io/java.util.json.Java21/</url>
18+
<scm>
19+
<connection>scm:git:https://github.com/simbo1905/java.util.json.Java21.git</connection>
20+
<developerConnection>scm:git:git@github.com:simbo1905/java.util.json.Java21.git</developerConnection>
21+
<url>https://github.com/simbo1905/java.util.json.Java21</url>
22+
<tag>HEAD</tag>
23+
</scm>
24+
<description>RFC 6901 JSON Pointer implementation for the java.util.json Java 21 backport; parses JSON Pointer expressions and resolves them against JSON documents.</description>
25+
26+
<properties>
27+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
28+
<maven.compiler.release>21</maven.compiler.release>
29+
</properties>
30+
31+
<dependencies>
32+
<dependency>
33+
<groupId>io.github.simbo1905.json</groupId>
34+
<artifactId>java.util.json</artifactId>
35+
<version>${project.version}</version>
36+
</dependency>
37+
38+
<!-- Test dependencies -->
39+
<dependency>
40+
<groupId>org.junit.jupiter</groupId>
41+
<artifactId>junit-jupiter-api</artifactId>
42+
<scope>test</scope>
43+
</dependency>
44+
<dependency>
45+
<groupId>org.junit.jupiter</groupId>
46+
<artifactId>junit-jupiter-engine</artifactId>
47+
<scope>test</scope>
48+
</dependency>
49+
<dependency>
50+
<groupId>org.junit.jupiter</groupId>
51+
<artifactId>junit-jupiter-params</artifactId>
52+
<scope>test</scope>
53+
</dependency>
54+
<dependency>
55+
<groupId>org.assertj</groupId>
56+
<artifactId>assertj-core</artifactId>
57+
<scope>test</scope>
58+
</dependency>
59+
</dependencies>
60+
61+
<build>
62+
<plugins>
63+
<!-- Treat all warnings as errors, enable all lint warnings -->
64+
<plugin>
65+
<groupId>org.apache.maven.plugins</groupId>
66+
<artifactId>maven-compiler-plugin</artifactId>
67+
<configuration>
68+
<release>21</release>
69+
<compilerArgs>
70+
<arg>-Xlint:all</arg>
71+
<arg>-Werror</arg>
72+
<arg>-Xdiags:verbose</arg>
73+
</compilerArgs>
74+
</configuration>
75+
</plugin>
76+
<plugin>
77+
<groupId>org.apache.maven.plugins</groupId>
78+
<artifactId>maven-surefire-plugin</artifactId>
79+
<configuration>
80+
<argLine>-ea</argLine>
81+
</configuration>
82+
</plugin>
83+
</plugins>
84+
</build>
85+
</project>
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
package json.java21.jsonpointer;
2+
3+
import jdk.sandbox.java.util.json.JsonArray;
4+
import jdk.sandbox.java.util.json.JsonObject;
5+
import jdk.sandbox.java.util.json.JsonValue;
6+
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import java.util.Objects;
10+
import java.util.Optional;
11+
import java.util.logging.Logger;
12+
13+
/// RFC 6901 JSON Pointer — an exact-address path into a JSON document.
14+
///
15+
/// A JSON Pointer is a Unicode string of zero or more reference tokens, each
16+
/// prefixed by `/`. The empty string `""` identifies the root document.
17+
/// Each token names an object member or an array index.
18+
///
19+
/// Escaping (RFC 6901 §3):
20+
///
21+
/// - `~1` → `/`
22+
/// - `~0` → `~`
23+
///
24+
/// The replacement order matters: `~1` is decoded before `~0` so that `~01`
25+
/// decodes to the literal string `~1` rather than `/`.
26+
///
27+
/// Usage:
28+
/// ```java
29+
/// JsonValue doc = Json.parse(jsonText);
30+
/// JsonValue value = JsonPointer.parse("/foo/0").resolve(doc);
31+
/// boolean found = JsonPointer.parse("/foo/0").exists(doc);
32+
/// ```
33+
///
34+
/// @see <a href="https://www.rfc-editor.org/rfc/rfc6901">RFC 6901 — JSON Pointer</a>
35+
public final class JsonPointer {
36+
37+
private static final Logger LOG = Logger.getLogger(JsonPointer.class.getName());
38+
39+
private final String raw;
40+
private final List<String> tokens;
41+
42+
private JsonPointer(String raw, List<String> tokens) {
43+
this.raw = raw;
44+
this.tokens = List.copyOf(tokens);
45+
}
46+
47+
/// Parses a JSON Pointer string.
48+
///
49+
/// The empty string `""` is valid and identifies the root document. Any
50+
/// non-empty pointer must begin with `/`. Escape sequences must be
51+
/// well-formed: `~` must be followed by `0` or `1`.
52+
///
53+
/// @param pointer the JSON Pointer string; must not be `null`
54+
/// @return a `JsonPointer` instance
55+
/// @throws NullPointerException if `pointer` is `null`
56+
/// @throws JsonPointerSyntaxException if `pointer` is not a valid JSON Pointer
57+
public static JsonPointer parse(String pointer) {
58+
Objects.requireNonNull(pointer, "pointer must not be null");
59+
LOG.fine(() -> "parsing JSON Pointer: \"" + pointer + "\"");
60+
61+
if (pointer.isEmpty()) {
62+
return new JsonPointer(pointer, List.of());
63+
}
64+
65+
if (pointer.charAt(0) != '/') {
66+
throw new JsonPointerSyntaxException(
67+
"non-empty JSON Pointer must start with '/'", pointer);
68+
}
69+
70+
// Split on '/' — the first char is '/' so the first segment is always empty;
71+
// skip it by starting at index 1.
72+
final var segments = pointer.substring(1).split("/", -1);
73+
final var tokenList = new ArrayList<String>(segments.length);
74+
for (final var segment : segments) {
75+
tokenList.add(decodeToken(segment, pointer));
76+
}
77+
78+
LOG.fine(() -> "parsed " + tokenList.size() + " token(s) from \"" + pointer + "\"");
79+
return new JsonPointer(pointer, tokenList);
80+
}
81+
82+
/// Returns the decoded reference tokens of this pointer.
83+
///
84+
/// The returned list is unmodifiable. An empty list means the pointer
85+
/// identifies the root document.
86+
///
87+
/// @return immutable list of decoded reference tokens
88+
public List<String> tokens() {
89+
return tokens;
90+
}
91+
92+
/// Resolves this pointer against `document`, returning the identified value.
93+
///
94+
/// @param document the JSON document to resolve against; must not be `null`
95+
/// @return the `JsonValue` identified by this pointer
96+
/// @throws NullPointerException if `document` is `null`
97+
/// @throws JsonPointerResolutionException if no value exists at this pointer
98+
public JsonValue resolve(JsonValue document) {
99+
Objects.requireNonNull(document, "document must not be null");
100+
LOG.fine(() -> "resolving pointer \"" + raw + "\" against document");
101+
102+
return resolveInternal(document).orElseThrow(
103+
() -> new JsonPointerResolutionException(
104+
"pointer does not identify an existing value", raw, null));
105+
}
106+
107+
/// Returns `true` if this pointer identifies an existing value in `document`.
108+
///
109+
/// @param document the JSON document; must not be `null`
110+
/// @return `true` when the pointed-to value exists, `false` otherwise
111+
/// @throws NullPointerException if `document` is `null`
112+
public boolean exists(JsonValue document) {
113+
Objects.requireNonNull(document, "document must not be null");
114+
return resolveInternal(document).isPresent();
115+
}
116+
117+
/// Returns the original JSON Pointer string as passed to [#parse(String)].
118+
///
119+
/// The returned value is a valid JSON Pointer and satisfies:
120+
/// `JsonPointer.parse(p.toString()).equals(p)`.
121+
///
122+
/// @return the canonical pointer string
123+
@Override
124+
public String toString() {
125+
return raw;
126+
}
127+
128+
@Override
129+
public boolean equals(Object obj) {
130+
return obj instanceof JsonPointer other && raw.equals(other.raw);
131+
}
132+
133+
@Override
134+
public int hashCode() {
135+
return raw.hashCode();
136+
}
137+
138+
// -------------------------------------------------------------------------
139+
// Internal helpers
140+
// -------------------------------------------------------------------------
141+
142+
/// Attempts to resolve this pointer; returns empty if any step fails.
143+
private Optional<JsonValue> resolveInternal(JsonValue document) {
144+
var current = document;
145+
for (final var token : tokens) {
146+
final var next = step(current, token);
147+
if (next.isEmpty()) {
148+
LOG.fine(() -> "resolution failed at token \"" + token
149+
+ "\" for pointer \"" + raw + "\"");
150+
return Optional.empty();
151+
}
152+
current = next.get();
153+
}
154+
return Optional.of(current);
155+
}
156+
157+
/// Applies a single reference token to `node`, returning the resulting value
158+
/// or empty if the step cannot be taken.
159+
private static Optional<JsonValue> step(JsonValue node, String token) {
160+
return switch (node) {
161+
case JsonObject obj -> {
162+
final var value = obj.members().get(token);
163+
yield Optional.ofNullable(value);
164+
}
165+
case JsonArray arr -> {
166+
final var elements = arr.elements();
167+
final var index = parseArrayIndex(token, elements.size());
168+
yield index >= 0 ? Optional.of(elements.get(index)) : Optional.empty();
169+
}
170+
default -> Optional.empty();
171+
};
172+
}
173+
174+
/// Parses an array reference token.
175+
///
176+
/// Returns the non-negative index when the token is a valid non-negative
177+
/// integer within bounds, or `-1` in every other case:
178+
///
179+
/// - `-` is the special "append" token defined by RFC 6902; it is not a
180+
/// valid resolution target in RFC 6901.
181+
/// - Leading zeros are forbidden (e.g. `"01"` is invalid per RFC 6901 §4).
182+
/// - Negative numbers are not permitted.
183+
/// - Any non-numeric token applied to an array fails resolution.
184+
///
185+
/// @param token the reference token
186+
/// @param length the array length
187+
/// @return the index, or `-1` on failure
188+
private static int parseArrayIndex(String token, int length) {
189+
if (token.isEmpty()) {
190+
return -1;
191+
}
192+
// '-' is the RFC 6902 append sentinel; not resolvable in RFC 6901
193+
if ("-".equals(token)) {
194+
return -1;
195+
}
196+
// Leading zeros are forbidden for non-zero values
197+
if (token.length() > 1 && token.charAt(0) == '0') {
198+
return -1;
199+
}
200+
// Must be all digits
201+
for (int i = 0; i < token.length(); i++) {
202+
if (!Character.isDigit(token.charAt(i))) {
203+
return -1;
204+
}
205+
}
206+
try {
207+
final var index = Integer.parseInt(token);
208+
return (index >= 0 && index < length) ? index : -1;
209+
} catch (NumberFormatException e) {
210+
// Overflow — value too large to be a valid index
211+
return -1;
212+
}
213+
}
214+
215+
/// Decodes a single reference token according to RFC 6901 §3.
216+
///
217+
/// The replacement order is mandated by the RFC:
218+
///
219+
/// 1. `~1` → `/`
220+
/// 2. `~0` → `~`
221+
///
222+
/// This order ensures that `~01` becomes `~1` (not `/`).
223+
///
224+
/// @param token the raw (encoded) reference token segment
225+
/// @param pointer the full pointer string (used in exception messages)
226+
/// @return the decoded token
227+
/// @throws JsonPointerSyntaxException if the token contains an invalid `~` escape
228+
static String decodeToken(String token, String pointer) {
229+
if (!token.contains("~")) {
230+
return token;
231+
}
232+
// Validate: every '~' must be followed by '0' or '1'
233+
for (int i = 0; i < token.length(); i++) {
234+
if (token.charAt(i) == '~') {
235+
if (i + 1 >= token.length()) {
236+
throw new JsonPointerSyntaxException(
237+
"invalid escape sequence: '~' at end of token", pointer);
238+
}
239+
final var next = token.charAt(i + 1);
240+
if (next != '0' && next != '1') {
241+
throw new JsonPointerSyntaxException(
242+
"invalid escape sequence '~" + next + "' in token", pointer);
243+
}
244+
}
245+
}
246+
// Decode in the RFC-mandated order: ~1 first, then ~0
247+
return token.replace("~1", "/").replace("~0", "~");
248+
}
249+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package json.java21.jsonpointer;
2+
3+
import java.io.Serial;
4+
5+
/// Exception thrown when a JSON Pointer cannot be resolved against a document.
6+
///
7+
/// Resolution failures are distinct from parse failures. A pointer may be
8+
/// syntactically valid but still fail to resolve because:
9+
///
10+
/// - A referenced object member does not exist.
11+
/// - An array index is out of bounds.
12+
/// - An array token is not a valid non-negative integer.
13+
/// - A step traverses a primitive value (string, number, boolean, null) that
14+
/// has no children.
15+
///
16+
/// @see JsonPointerSyntaxException
17+
public final class JsonPointerResolutionException extends RuntimeException {
18+
19+
@Serial
20+
private static final long serialVersionUID = 1L;
21+
22+
private final String pointer;
23+
private final String failingToken;
24+
25+
/// Creates a new resolution exception.
26+
///
27+
/// @param message human-readable description of the resolution failure
28+
/// @param pointer the pointer that failed to resolve
29+
/// @param failingToken the reference token that could not be applied, or
30+
/// `null` if the whole pointer is the context
31+
public JsonPointerResolutionException(String message, String pointer, String failingToken) {
32+
super(formatMessage(message, pointer, failingToken));
33+
this.pointer = pointer;
34+
this.failingToken = failingToken;
35+
}
36+
37+
/// Returns the pointer that failed to resolve.
38+
///
39+
/// @return the pointer string
40+
public String pointer() {
41+
return pointer;
42+
}
43+
44+
/// Returns the reference token that could not be applied, or `null` when not
45+
/// applicable.
46+
///
47+
/// @return the failing token, or `null`
48+
public String failingToken() {
49+
return failingToken;
50+
}
51+
52+
private static String formatMessage(String message, String pointer, String failingToken) {
53+
if (failingToken == null) {
54+
return message + ": pointer=\"" + pointer + "\"";
55+
}
56+
return message + ": pointer=\"" + pointer + "\", token=\"" + failingToken + "\"";
57+
}
58+
}

0 commit comments

Comments
 (0)