From 1f68ebdabc27d090003aef46e93d60c39ded2cb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:06:08 +0000 Subject: [PATCH 1/2] Issue: implement RFC 6901 JSON Pointer as json-java21-jsonpointer module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- json-java21-jsonpointer/pom.xml | 85 ++++ .../json/java21/jsonpointer/JsonPointer.java | 249 ++++++++++++ .../JsonPointerResolutionException.java | 58 +++ .../JsonPointerSyntaxException.java | 40 ++ .../jsonpointer/JsonPointerLoggingConfig.java | 42 ++ .../java21/jsonpointer/JsonPointerTest.java | 370 ++++++++++++++++++ pom.xml | 1 + 7 files changed, 845 insertions(+) create mode 100644 json-java21-jsonpointer/pom.xml create mode 100644 json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointer.java create mode 100644 json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointerResolutionException.java create mode 100644 json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointerSyntaxException.java create mode 100644 json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerLoggingConfig.java create mode 100644 json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerTest.java diff --git a/json-java21-jsonpointer/pom.xml b/json-java21-jsonpointer/pom.xml new file mode 100644 index 0000000..d0f9a3d --- /dev/null +++ b/json-java21-jsonpointer/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + + io.github.simbo1905.json + parent + 0.1.9 + + + java.util.json.jsonpointer + jar + java.util.json Java21 Backport JSON Pointer + https://simbo1905.github.io/java.util.json.Java21/ + + scm:git:https://github.com/simbo1905/java.util.json.Java21.git + scm:git:git@github.com:simbo1905/java.util.json.Java21.git + https://github.com/simbo1905/java.util.json.Java21 + HEAD + + RFC 6901 JSON Pointer implementation for the java.util.json Java 21 backport; parses JSON Pointer expressions and resolves them against JSON documents. + + + UTF-8 + 21 + + + + + io.github.simbo1905.json + java.util.json + ${project.version} + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.assertj + assertj-core + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 21 + + -Xlint:all + -Werror + -Xdiags:verbose + + + + + org.apache.maven.plugins + maven-surefire-plugin + + -ea + + + + + diff --git a/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointer.java b/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointer.java new file mode 100644 index 0000000..58637be --- /dev/null +++ b/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointer.java @@ -0,0 +1,249 @@ +package json.java21.jsonpointer; + +import jdk.sandbox.java.util.json.JsonArray; +import jdk.sandbox.java.util.json.JsonObject; +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.logging.Logger; + +/// RFC 6901 JSON Pointer — an exact-address path into a JSON document. +/// +/// A JSON Pointer is a Unicode string of zero or more reference tokens, each +/// prefixed by `/`. The empty string `""` identifies the root document. +/// Each token names an object member or an array index. +/// +/// Escaping (RFC 6901 §3): +/// +/// - `~1` → `/` +/// - `~0` → `~` +/// +/// The replacement order matters: `~1` is decoded before `~0` so that `~01` +/// decodes to the literal string `~1` rather than `/`. +/// +/// Usage: +/// ```java +/// JsonValue doc = Json.parse(jsonText); +/// JsonValue value = JsonPointer.parse("/foo/0").resolve(doc); +/// boolean found = JsonPointer.parse("/foo/0").exists(doc); +/// ``` +/// +/// @see RFC 6901 — JSON Pointer +public final class JsonPointer { + + private static final Logger LOG = Logger.getLogger(JsonPointer.class.getName()); + + private final String raw; + private final List tokens; + + private JsonPointer(String raw, List tokens) { + this.raw = raw; + this.tokens = List.copyOf(tokens); + } + + /// Parses a JSON Pointer string. + /// + /// The empty string `""` is valid and identifies the root document. Any + /// non-empty pointer must begin with `/`. Escape sequences must be + /// well-formed: `~` must be followed by `0` or `1`. + /// + /// @param pointer the JSON Pointer string; must not be `null` + /// @return a `JsonPointer` instance + /// @throws NullPointerException if `pointer` is `null` + /// @throws JsonPointerSyntaxException if `pointer` is not a valid JSON Pointer + public static JsonPointer parse(String pointer) { + Objects.requireNonNull(pointer, "pointer must not be null"); + LOG.fine(() -> "parsing JSON Pointer: \"" + pointer + "\""); + + if (pointer.isEmpty()) { + return new JsonPointer(pointer, List.of()); + } + + if (pointer.charAt(0) != '/') { + throw new JsonPointerSyntaxException( + "non-empty JSON Pointer must start with '/'", pointer); + } + + // Split on '/' — the first char is '/' so the first segment is always empty; + // skip it by starting at index 1. + final var segments = pointer.substring(1).split("/", -1); + final var tokenList = new ArrayList(segments.length); + for (final var segment : segments) { + tokenList.add(decodeToken(segment, pointer)); + } + + LOG.fine(() -> "parsed " + tokenList.size() + " token(s) from \"" + pointer + "\""); + return new JsonPointer(pointer, tokenList); + } + + /// Returns the decoded reference tokens of this pointer. + /// + /// The returned list is unmodifiable. An empty list means the pointer + /// identifies the root document. + /// + /// @return immutable list of decoded reference tokens + public List tokens() { + return tokens; + } + + /// Resolves this pointer against `document`, returning the identified value. + /// + /// @param document the JSON document to resolve against; must not be `null` + /// @return the `JsonValue` identified by this pointer + /// @throws NullPointerException if `document` is `null` + /// @throws JsonPointerResolutionException if no value exists at this pointer + public JsonValue resolve(JsonValue document) { + Objects.requireNonNull(document, "document must not be null"); + LOG.fine(() -> "resolving pointer \"" + raw + "\" against document"); + + return resolveInternal(document).orElseThrow( + () -> new JsonPointerResolutionException( + "pointer does not identify an existing value", raw, null)); + } + + /// Returns `true` if this pointer identifies an existing value in `document`. + /// + /// @param document the JSON document; must not be `null` + /// @return `true` when the pointed-to value exists, `false` otherwise + /// @throws NullPointerException if `document` is `null` + public boolean exists(JsonValue document) { + Objects.requireNonNull(document, "document must not be null"); + return resolveInternal(document).isPresent(); + } + + /// Returns the original JSON Pointer string as passed to [#parse(String)]. + /// + /// The returned value is a valid JSON Pointer and satisfies: + /// `JsonPointer.parse(p.toString()).equals(p)`. + /// + /// @return the canonical pointer string + @Override + public String toString() { + return raw; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof JsonPointer other && raw.equals(other.raw); + } + + @Override + public int hashCode() { + return raw.hashCode(); + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + /// Attempts to resolve this pointer; returns empty if any step fails. + private Optional resolveInternal(JsonValue document) { + var current = document; + for (final var token : tokens) { + final var next = step(current, token); + if (next.isEmpty()) { + LOG.fine(() -> "resolution failed at token \"" + token + + "\" for pointer \"" + raw + "\""); + return Optional.empty(); + } + current = next.get(); + } + return Optional.of(current); + } + + /// Applies a single reference token to `node`, returning the resulting value + /// or empty if the step cannot be taken. + private static Optional step(JsonValue node, String token) { + return switch (node) { + case JsonObject obj -> { + final var value = obj.members().get(token); + yield Optional.ofNullable(value); + } + case JsonArray arr -> { + final var elements = arr.elements(); + final var index = parseArrayIndex(token, elements.size()); + yield index >= 0 ? Optional.of(elements.get(index)) : Optional.empty(); + } + default -> Optional.empty(); + }; + } + + /// Parses an array reference token. + /// + /// Returns the non-negative index when the token is a valid non-negative + /// integer within bounds, or `-1` in every other case: + /// + /// - `-` is the special "append" token defined by RFC 6902; it is not a + /// valid resolution target in RFC 6901. + /// - Leading zeros are forbidden (e.g. `"01"` is invalid per RFC 6901 §4). + /// - Negative numbers are not permitted. + /// - Any non-numeric token applied to an array fails resolution. + /// + /// @param token the reference token + /// @param length the array length + /// @return the index, or `-1` on failure + private static int parseArrayIndex(String token, int length) { + if (token.isEmpty()) { + return -1; + } + // '-' is the RFC 6902 append sentinel; not resolvable in RFC 6901 + if ("-".equals(token)) { + return -1; + } + // Leading zeros are forbidden for non-zero values + if (token.length() > 1 && token.charAt(0) == '0') { + return -1; + } + // Must be all digits + for (int i = 0; i < token.length(); i++) { + if (!Character.isDigit(token.charAt(i))) { + return -1; + } + } + try { + final var index = Integer.parseInt(token); + return (index >= 0 && index < length) ? index : -1; + } catch (NumberFormatException e) { + // Overflow — value too large to be a valid index + return -1; + } + } + + /// Decodes a single reference token according to RFC 6901 §3. + /// + /// The replacement order is mandated by the RFC: + /// + /// 1. `~1` → `/` + /// 2. `~0` → `~` + /// + /// This order ensures that `~01` becomes `~1` (not `/`). + /// + /// @param token the raw (encoded) reference token segment + /// @param pointer the full pointer string (used in exception messages) + /// @return the decoded token + /// @throws JsonPointerSyntaxException if the token contains an invalid `~` escape + static String decodeToken(String token, String pointer) { + if (!token.contains("~")) { + return token; + } + // Validate: every '~' must be followed by '0' or '1' + for (int i = 0; i < token.length(); i++) { + if (token.charAt(i) == '~') { + if (i + 1 >= token.length()) { + throw new JsonPointerSyntaxException( + "invalid escape sequence: '~' at end of token", pointer); + } + final var next = token.charAt(i + 1); + if (next != '0' && next != '1') { + throw new JsonPointerSyntaxException( + "invalid escape sequence '~" + next + "' in token", pointer); + } + } + } + // Decode in the RFC-mandated order: ~1 first, then ~0 + return token.replace("~1", "/").replace("~0", "~"); + } +} diff --git a/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointerResolutionException.java b/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointerResolutionException.java new file mode 100644 index 0000000..80e2bb5 --- /dev/null +++ b/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointerResolutionException.java @@ -0,0 +1,58 @@ +package json.java21.jsonpointer; + +import java.io.Serial; + +/// Exception thrown when a JSON Pointer cannot be resolved against a document. +/// +/// Resolution failures are distinct from parse failures. A pointer may be +/// syntactically valid but still fail to resolve because: +/// +/// - A referenced object member does not exist. +/// - An array index is out of bounds. +/// - An array token is not a valid non-negative integer. +/// - A step traverses a primitive value (string, number, boolean, null) that +/// has no children. +/// +/// @see JsonPointerSyntaxException +public final class JsonPointerResolutionException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + private final String pointer; + private final String failingToken; + + /// Creates a new resolution exception. + /// + /// @param message human-readable description of the resolution failure + /// @param pointer the pointer that failed to resolve + /// @param failingToken the reference token that could not be applied, or + /// `null` if the whole pointer is the context + public JsonPointerResolutionException(String message, String pointer, String failingToken) { + super(formatMessage(message, pointer, failingToken)); + this.pointer = pointer; + this.failingToken = failingToken; + } + + /// Returns the pointer that failed to resolve. + /// + /// @return the pointer string + public String pointer() { + return pointer; + } + + /// Returns the reference token that could not be applied, or `null` when not + /// applicable. + /// + /// @return the failing token, or `null` + public String failingToken() { + return failingToken; + } + + private static String formatMessage(String message, String pointer, String failingToken) { + if (failingToken == null) { + return message + ": pointer=\"" + pointer + "\""; + } + return message + ": pointer=\"" + pointer + "\", token=\"" + failingToken + "\""; + } +} diff --git a/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointerSyntaxException.java b/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointerSyntaxException.java new file mode 100644 index 0000000..8a1edd1 --- /dev/null +++ b/json-java21-jsonpointer/src/main/java/json/java21/jsonpointer/JsonPointerSyntaxException.java @@ -0,0 +1,40 @@ +package json.java21.jsonpointer; + +import java.io.Serial; + +/// Exception thrown when a JSON Pointer string cannot be parsed. +/// +/// A non-empty JSON Pointer must start with `/`. Escape sequences must be +/// well-formed: `~` must be followed by `0` or `1`; no other tilde sequences +/// are permitted (RFC 6901 §3). +/// +/// This is an unchecked exception because pointer syntax errors are typically +/// programming errors discovered at development time. +public final class JsonPointerSyntaxException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + private final String pointer; + + /// Creates a new syntax exception with the given message and the pointer string + /// that was being parsed. + /// + /// @param message human-readable description of the syntax error + /// @param pointer the invalid pointer string + public JsonPointerSyntaxException(String message, String pointer) { + super(formatMessage(message, pointer)); + this.pointer = pointer; + } + + /// Returns the pointer string that failed to parse. + /// + /// @return the invalid pointer string + public String pointer() { + return pointer; + } + + private static String formatMessage(String message, String pointer) { + return message + ": \"" + pointer + "\""; + } +} diff --git a/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerLoggingConfig.java b/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerLoggingConfig.java new file mode 100644 index 0000000..48a6bab --- /dev/null +++ b/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerLoggingConfig.java @@ -0,0 +1,42 @@ +package json.java21.jsonpointer; + +import org.junit.jupiter.api.BeforeAll; + +import java.util.Locale; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; + +/// Base class for JSON Pointer tests that configures JUL logging from system properties. +/// All test classes should extend this class to enable consistent logging behaviour. +public class JsonPointerLoggingConfig { + + @BeforeAll + static void enableJulDebug() { + final var root = Logger.getLogger(""); + final var levelProp = System.getProperty("java.util.logging.ConsoleHandler.level"); + var targetLevel = Level.INFO; + if (levelProp != null) { + try { + targetLevel = Level.parse(levelProp.trim()); + } catch (IllegalArgumentException ex) { + try { + targetLevel = Level.parse(levelProp.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + Logger.getLogger(JsonPointerLoggingConfig.class.getName()) + .warning(() -> "Unrecognized logging level from " + + "'java.util.logging.ConsoleHandler.level': " + levelProp); + } + } + } + if (root.getLevel() == null || root.getLevel().intValue() > targetLevel.intValue()) { + root.setLevel(targetLevel); + } + for (final Handler handler : root.getHandlers()) { + final var handlerLevel = handler.getLevel(); + if (handlerLevel == null || handlerLevel.intValue() > targetLevel.intValue()) { + handler.setLevel(targetLevel); + } + } + } +} diff --git a/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerTest.java b/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerTest.java new file mode 100644 index 0000000..7eb30ca --- /dev/null +++ b/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerTest.java @@ -0,0 +1,370 @@ +package json.java21.jsonpointer; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonArray; +import jdk.sandbox.java.util.json.JsonNull; +import jdk.sandbox.java.util.json.JsonObject; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/// RFC 6901 JSON Pointer specification tests. +/// +/// Section 5 of RFC 6901 defines an example document and a table of pointer +/// expressions with their expected resolved values. Every entry in that table +/// is covered here, plus additional edge-case tests required by the issue. +/// +/// @see RFC 6901 §5 +class JsonPointerTest extends JsonPointerLoggingConfig { + + private static final Logger LOG = Logger.getLogger(JsonPointerTest.class.getName()); + + // ------------------------------------------------------------------------- + // RFC 6901 §5 example document + // ------------------------------------------------------------------------- + + private static final String RFC_DOCUMENT = """ + { + "foo": ["bar", "baz"], + "": 0, + "a/b": 1, + "c%d": 2, + "e^f": 3, + "g|h": 4, + "i\\\\j": 5, + "k\\"l": 6, + " ": 7, + "m~n": 8 + } + """; + + private static JsonValue rfcDoc() { + return Json.parse(RFC_DOCUMENT); + } + + // ------------------------------------------------------------------------- + // RFC 6901 §5 example table — every row + // ------------------------------------------------------------------------- + + @Test + void emptyPointerResolvesToWholeDocument() { + LOG.info("TEST: emptyPointerResolvesToWholeDocument"); + final var doc = rfcDoc(); + final var result = JsonPointer.parse("").resolve(doc); + assertThat(result).isInstanceOf(JsonObject.class); + assertThat(result).isSameAs(doc); + } + + @Test + void fooResolvesToArray() { + LOG.info("TEST: fooResolvesToArray"); + final var result = JsonPointer.parse("/foo").resolve(rfcDoc()); + assertThat(result).isInstanceOf(JsonArray.class); + assertThat(((JsonArray) result).elements()).hasSize(2); + } + + @Test + void fooZeroResolvesToFirstArrayElement() { + LOG.info("TEST: fooZeroResolvesToFirstArrayElement"); + final var result = JsonPointer.parse("/foo/0").resolve(rfcDoc()); + assertThat(result.toString()).isEqualTo("\"bar\""); + } + + @Test + void slashAloneResolvesToEmptyStringKey() { + LOG.info("TEST: slashAloneResolvesToEmptyStringKey"); + final var result = JsonPointer.parse("/").resolve(rfcDoc()); + assertThat(result.toString()).isEqualTo("0"); + } + + @Test + void tilde1DecodesSlashInKey() { + LOG.info("TEST: tilde1DecodesSlashInKey - /a~1b resolves key 'a/b'"); + final var result = JsonPointer.parse("/a~1b").resolve(rfcDoc()); + assertThat(result.toString()).isEqualTo("1"); + } + + @Test + void percentInKeyIsLiteral() { + LOG.info("TEST: percentInKeyIsLiteral - /c%d resolves key 'c%d'"); + final var result = JsonPointer.parse("/c%d").resolve(rfcDoc()); + assertThat(result.toString()).isEqualTo("2"); + } + + @Test + void caretInKeyIsLiteral() { + LOG.info("TEST: caretInKeyIsLiteral - /e^f resolves key 'e^f'"); + final var result = JsonPointer.parse("/e^f").resolve(rfcDoc()); + assertThat(result.toString()).isEqualTo("3"); + } + + @Test + void pipeInKeyIsLiteral() { + LOG.info("TEST: pipeInKeyIsLiteral - /g|h resolves key 'g|h'"); + final var result = JsonPointer.parse("/g|h").resolve(rfcDoc()); + assertThat(result.toString()).isEqualTo("4"); + } + + @Test + void backslashInKeyIsLiteral() { + LOG.info("TEST: backslashInKeyIsLiteral - /i\\\\j resolves key with backslash"); + // The JSON key is "i\j" (one backslash). The pointer token is also "i\j". + final var result = JsonPointer.parse("/i\\j").resolve(rfcDoc()); + assertThat(result.toString()).isEqualTo("5"); + } + + @Test + void doubleQuoteInKeyIsLiteral() { + LOG.info("TEST: doubleQuoteInKeyIsLiteral - /k\\\"l resolves key with quote"); + // The JSON key is k"l. The pointer token is k"l. + final var result = JsonPointer.parse("/k\"l").resolve(rfcDoc()); + assertThat(result.toString()).isEqualTo("6"); + } + + @Test + void spaceKeyIsLiteral() { + LOG.info("TEST: spaceKeyIsLiteral - '/ ' resolves key ' '"); + final var result = JsonPointer.parse("/ ").resolve(rfcDoc()); + assertThat(result.toString()).isEqualTo("7"); + } + + @Test + void tilde0DecodestiLdeInKey() { + LOG.info("TEST: tilde0DecodesTildeInKey - /m~0n resolves key 'm~n'"); + final var result = JsonPointer.parse("/m~0n").resolve(rfcDoc()); + assertThat(result.toString()).isEqualTo("8"); + } + + // ------------------------------------------------------------------------- + // Escape ordering — tricky token + // ------------------------------------------------------------------------- + + @Test + void escapeOrderingTildeOneZeroDecodesToTildeOne() { + LOG.info("TEST: escapeOrderingTildeOneZeroDecodesToTildeOne - ~01 decodes to ~1 not /"); + // ~01 must decode as: first ~1→/, then ~0→~ ... wait, that is wrong. + // RFC says: replace ~1 first, then ~0. + // ~01 contains the subsequence ~0 and the digit 1. + // Step 1 (replace ~1): ~01 has no ~1 at position 0 (it is ~0), so no change: ~01 + // Step 2 (replace ~0): ~0 → ~: result is ~1 + // So ~01 decodes to the string "~1". + final var token = JsonPointer.decodeToken("~01", "dummy"); + assertThat(token).isEqualTo("~1"); + } + + @Test + void escapeOrderingTildeOneDecodesToSlash() { + LOG.info("TEST: escapeOrderingTildeOneDecodesToSlash"); + assertThat(JsonPointer.decodeToken("~1", "dummy")).isEqualTo("/"); + } + + @Test + void escapeOrderingTildeZeroDecodesToTilde() { + LOG.info("TEST: escapeOrderingTildeZeroDecodesToTilde"); + assertThat(JsonPointer.decodeToken("~0", "dummy")).isEqualTo("~"); + } + + // ------------------------------------------------------------------------- + // tokens() accessor + // ------------------------------------------------------------------------- + + @Test + void tokensForEmptyPointerIsEmpty() { + LOG.info("TEST: tokensForEmptyPointerIsEmpty"); + assertThat(JsonPointer.parse("").tokens()).isEqualTo(List.of()); + } + + @Test + void tokensForSimplePointer() { + LOG.info("TEST: tokensForSimplePointer"); + assertThat(JsonPointer.parse("/foo/bar").tokens()).isEqualTo(List.of("foo", "bar")); + } + + @Test + void tokensAreDecoded() { + LOG.info("TEST: tokensAreDecoded"); + assertThat(JsonPointer.parse("/a~1b/m~0n").tokens()).isEqualTo(List.of("a/b", "m~n")); + } + + @Test + void tokensListIsUnmodifiable() { + LOG.info("TEST: tokensListIsUnmodifiable"); + final var tokens = JsonPointer.parse("/foo").tokens(); + assertThatThrownBy(() -> tokens.add("bar")) + .isInstanceOf(UnsupportedOperationException.class); + } + + // ------------------------------------------------------------------------- + // toString() round-trip + // ------------------------------------------------------------------------- + + @ParameterizedTest(name = "round-trip: {0}") + @ValueSource(strings = {"", "/", "/foo", "/foo/0", "/a~1b", "/m~0n", "/a~1b/m~0n"}) + void toStringRoundTrip(String pointer) { + LOG.info(() -> "TEST: toStringRoundTrip - " + pointer); + assertThat(JsonPointer.parse(pointer).toString()).isEqualTo(pointer); + } + + // ------------------------------------------------------------------------- + // exists() + // ------------------------------------------------------------------------- + + @Test + void existsTrueForValidPath() { + LOG.info("TEST: existsTrueForValidPath"); + assertThat(JsonPointer.parse("/foo").exists(rfcDoc())).isTrue(); + } + + @Test + void existsFalseForMissingKey() { + LOG.info("TEST: existsFalseForMissingKey"); + assertThat(JsonPointer.parse("/nonexistent").exists(rfcDoc())).isFalse(); + } + + @Test + void existsFalseForOutOfBoundsIndex() { + LOG.info("TEST: existsFalseForOutOfBoundsIndex"); + assertThat(JsonPointer.parse("/foo/5").exists(rfcDoc())).isFalse(); + } + + // ------------------------------------------------------------------------- + // Invalid pointer syntax + // ------------------------------------------------------------------------- + + @ParameterizedTest(name = "invalid syntax: \"{0}\"") + @ValueSource(strings = {"foo", "foo/bar", "~", "/foo/~2", "/foo/~", "/~"}) + void invalidPointerThrowsSyntaxException(String pointer) { + LOG.info(() -> "TEST: invalidPointerThrowsSyntaxException - " + pointer); + assertThatThrownBy(() -> JsonPointer.parse(pointer)) + .isInstanceOf(JsonPointerSyntaxException.class); + } + + @Test + void syntaxExceptionCarriesPointerString() { + LOG.info("TEST: syntaxExceptionCarriesPointerString"); + final var pointer = "bad"; + assertThatThrownBy(() -> JsonPointer.parse(pointer)) + .isInstanceOf(JsonPointerSyntaxException.class) + .satisfies(ex -> assertThat(((JsonPointerSyntaxException) ex).pointer()) + .isEqualTo(pointer)); + } + + @Test + void nullPointerThrowsNpe() { + LOG.info("TEST: nullPointerThrowsNpe"); + assertThatThrownBy(() -> JsonPointer.parse(null)) + .isInstanceOf(NullPointerException.class); + } + + // ------------------------------------------------------------------------- + // Resolution failures + // ------------------------------------------------------------------------- + + @Test + void missingObjectKeyThrowsResolutionException() { + LOG.info("TEST: missingObjectKeyThrowsResolutionException"); + assertThatThrownBy(() -> JsonPointer.parse("/missing").resolve(rfcDoc())) + .isInstanceOf(JsonPointerResolutionException.class); + } + + @Test + void arrayIndexOutOfBoundsThrowsResolutionException() { + LOG.info("TEST: arrayIndexOutOfBoundsThrowsResolutionException"); + assertThatThrownBy(() -> JsonPointer.parse("/foo/2").resolve(rfcDoc())) + .isInstanceOf(JsonPointerResolutionException.class); + } + + @Test + void nonNumericArrayTokenThrowsResolutionException() { + LOG.info("TEST: nonNumericArrayTokenThrowsResolutionException"); + assertThatThrownBy(() -> JsonPointer.parse("/foo/bar").resolve(rfcDoc())) + .isInstanceOf(JsonPointerResolutionException.class); + } + + @Test + void traversingPrimitiveThrowsResolutionException() { + LOG.info("TEST: traversingPrimitiveThrowsResolutionException"); + // "/foo/0" resolves to the string "bar"; going one level deeper should fail. + assertThatThrownBy(() -> JsonPointer.parse("/foo/0/nested").resolve(rfcDoc())) + .isInstanceOf(JsonPointerResolutionException.class); + } + + @Test + void dashTokenOnArrayFailsResolution() { + LOG.info("TEST: dashTokenOnArrayFailsResolution - '-' is not a valid RFC 6901 index"); + // '-' is defined as the append sentinel in RFC 6902 only. + assertThatThrownBy(() -> JsonPointer.parse("/foo/-").resolve(rfcDoc())) + .isInstanceOf(JsonPointerResolutionException.class); + } + + @Test + void nullDocumentThrowsNpe() { + LOG.info("TEST: nullDocumentThrowsNpe"); + assertThatThrownBy(() -> JsonPointer.parse("/foo").resolve(null)) + .isInstanceOf(NullPointerException.class); + } + + // ------------------------------------------------------------------------- + // Array index edge cases + // ------------------------------------------------------------------------- + + @Test + void arrayIndexOneIsValid() { + LOG.info("TEST: arrayIndexOneIsValid"); + final var result = JsonPointer.parse("/foo/1").resolve(rfcDoc()); + assertThat(result.toString()).isEqualTo("\"baz\""); + } + + @Test + void leadingZeroArrayIndexFailsResolution() { + LOG.info("TEST: leadingZeroArrayIndexFailsResolution - '01' is not a valid RFC 6901 index"); + // RFC 6901 §6 forbids leading zeros for non-zero indices. + assertThatThrownBy(() -> JsonPointer.parse("/foo/01").resolve(rfcDoc())) + .isInstanceOf(JsonPointerResolutionException.class); + } + + @Test + void negativeArrayIndexFailsResolution() { + LOG.info("TEST: negativeArrayIndexFailsResolution"); + assertThatThrownBy(() -> JsonPointer.parse("/foo/-1").resolve(rfcDoc())) + .isInstanceOf(JsonPointerResolutionException.class); + } + + // ------------------------------------------------------------------------- + // JSON null value is resolvable + // ------------------------------------------------------------------------- + + @Test + void nullValueInDocumentIsResolvable() { + LOG.info("TEST: nullValueInDocumentIsResolvable"); + final var doc = Json.parse("{\"key\": null}"); + final var result = JsonPointer.parse("/key").resolve(doc); + assertThat(result).isInstanceOf(JsonNull.class); + } + + // ------------------------------------------------------------------------- + // Nested document + // ------------------------------------------------------------------------- + + @ParameterizedTest(name = "nested resolve: {0} -> {1}") + @CsvSource({ + "/a, 1", + "/b/c, 2", + "/b/d/0, 3", + "/b/d/1, 4" + }) + void nestedDocumentResolution(String pointer, String expectedJson) { + LOG.info(() -> "TEST: nestedDocumentResolution - " + pointer + " -> " + expectedJson); + final var doc = Json.parse("{\"a\":1,\"b\":{\"c\":2,\"d\":[3,4]}}"); + final var result = JsonPointer.parse(pointer.trim()).resolve(doc); + assertThat(result.toString()).isEqualTo(expectedJson.trim()); + } +} diff --git a/pom.xml b/pom.xml index 6f983d9..8c90d80 100644 --- a/pom.xml +++ b/pom.xml @@ -42,6 +42,7 @@ json-compatibility-suite json-java21-jtd json-java21-jsonpath + json-java21-jsonpointer From cfb2f3fd7d24094c4a1f438653453722f6cf6abd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:07:56 +0000 Subject: [PATCH 2/2] fix: correct typo in test method name tilde0DecodestiLdeInKey -> tilde0DecodesTildeInKey --- .../src/test/java/json/java21/jsonpointer/JsonPointerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerTest.java b/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerTest.java index 7eb30ca..5d6e56d 100644 --- a/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerTest.java +++ b/json-java21-jsonpointer/src/test/java/json/java21/jsonpointer/JsonPointerTest.java @@ -137,7 +137,7 @@ void spaceKeyIsLiteral() { } @Test - void tilde0DecodestiLdeInKey() { + void tilde0DecodesTildeInKey() { LOG.info("TEST: tilde0DecodesTildeInKey - /m~0n resolves key 'm~n'"); final var result = JsonPointer.parse("/m~0n").resolve(rfcDoc()); assertThat(result.toString()).isEqualTo("8");