diff --git a/src/main/java/com/networknt/schema/PathType.java b/src/main/java/com/networknt/schema/PathType.java index 7037a300a..0fc99c4c5 100644 --- a/src/main/java/com/networknt/schema/PathType.java +++ b/src/main/java/com/networknt/schema/PathType.java @@ -1,6 +1,7 @@ package com.networknt.schema; import java.util.function.Function; +import java.util.function.IntPredicate; /** * Enumeration defining the different approached available to generate the paths added to validation messages. @@ -11,27 +12,38 @@ public enum PathType { * The legacy approach, loosely based on JSONPath (but not guaranteed to give valid JSONPath expressions). */ LEGACY("$", (token) -> "." + token, (index) -> "[" + index + "]"), + /** * Paths as JSONPath expressions. */ JSON_PATH("$", (token) -> { + + if (token.isEmpty()) { + throw new IllegalArgumentException("A JSONPath selector cannot be empty"); + } + + String t = token; /* * Accepted characters for shorthand paths: * - 'a' through 'z' * - 'A' through 'Z' * - '0' through '9' * - Underscore ('_') + * - any non-ASCII Unicode character */ - if (token.codePoints().allMatch(c -> (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_')) { - return "." + token; - } else { - if (token.indexOf('\"') != -1) { - // Make sure also any double quotes are escaped. - token = token.replace("\"", "\\\""); - } - return "[\"" + token + "\"]"; + if (JSONPath.isShorthand(t)) { + return "." + t; } + + boolean containsApostrophe = 0 <= t.indexOf('\''); + if (containsApostrophe) { + // Make sure also any apostrophes are escaped. + t = t.replace("'", "\\'"); + } + + return "['" + token + "']"; }, (index) -> "[" + index + "]"), + /** * Paths as JSONPointer expressions. */ @@ -77,7 +89,7 @@ public enum PathType { * @return The resulting complete path. */ public String append(String currentPath, String child) { - return currentPath + appendTokenFn.apply(child); + return currentPath + this.appendTokenFn.apply(child); } /** @@ -88,7 +100,7 @@ public String append(String currentPath, String child) { * @return The resulting complete path. */ public String append(String currentPath, int index) { - return currentPath + appendIndexFn.apply(index); + return currentPath + this.appendIndexFn.apply(index); } /** @@ -97,22 +109,137 @@ public String append(String currentPath, int index) { * @return The root token. */ public String getRoot() { - return rootToken; + return this.rootToken; } public String convertToJsonPointer(String path) { switch (this) { case JSON_POINTER: return path; - default: return fromLegacyOrJsonPath(path); + case JSON_PATH: return fromJsonPath(path); + default: return fromLegacy(path); } } - static String fromLegacyOrJsonPath(String path) { + static String fromLegacy(String path) { return path - .replace("\"", "") - .replace("]", "") - .replace('[', '/') - .replace('.', '/') - .replace("$", ""); + .replace("\"", "") + .replace("]", "") + .replace('[', '/') + .replace('.', '/') + .replace("$", ""); + } + + static String fromJsonPath(String str) { + if (null == str || str.isEmpty() || '$' != str.charAt(0)) { + throw new IllegalArgumentException("JSON Path must start with '$'"); + } + + String tail = str.substring(1); + if (tail.isEmpty()) { + return ""; + } + + int len = tail.length(); + StringBuilder sb = new StringBuilder(len); + for (int i = 0; i < len;) { + char c = tail.charAt(i); + switch (c) { + case '.': sb.append('/'); i = parseShorthand(sb, tail, i + 1); break; + case '[': sb.append('/'); i = parseSelector(sb, tail, i + 1); break; + default: throw new IllegalArgumentException("JSONPath must reference a property or array index"); + } + } + return sb.toString(); + } + + /** + * Parses a JSONPath shorthand selector + * @param sb receives the result + * @param s the source string + * @param pos the index into s immediately following the dot + * @return the index following the selector name + */ + static int parseShorthand(StringBuilder sb, String s, int pos) { + int len = s.length(); + int i = pos; + for (; i < len; ++i) { + char c = s.charAt(i); + switch (c) { + case '.': + case '[': + break; + default: + sb.append(c); + break; + } + } + return i; + } + + /** + * Parses a JSONPath selector + * @param sb receives the result + * @param s the source string + * @param pos the index into s immediately following the open bracket + * @return the index following the closing bracket + */ + static int parseSelector(StringBuilder sb, String s, int pos) { + int close = s.indexOf(']', pos); + if (-1 == close) { + throw new IllegalArgumentException("JSONPath contains an unterminated selector"); + } + + if ('\'' == s.charAt(pos)) { + parseQuote(sb, s, pos + 1); + } else { + sb.append(s.substring(pos, close)); + } + + return close + 1; + } + + /** + * Parses a single-quoted string. + * @param sb receives the result + * @param s the source string + * @param pos the index into s immediately following the open quote + * @return the index following the closing quote + */ + static int parseQuote(StringBuilder sb, String s, int pos) { + int close = pos; + do { + close = s.indexOf('\'', close); + if (-1 == close) { + throw new IllegalArgumentException("JSONPath contains an unterminated quoted string"); + } + } while ('\\' == s.charAt(close - 1)) ; + sb.append(s.substring(pos, close)); + return close + 1; + } + + static class JSONPath { + public static final IntPredicate ALPHA = c -> (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + public static final IntPredicate DIGIT = c -> c >= '0' && c <= '9'; + public static final IntPredicate NON_ASCII = c -> (c >= 0x80 && c <= 0x10FFFF); + public static final IntPredicate UNDERSCORE = c -> '_' == c; + + public static final IntPredicate NAME_FIRST = ALPHA.or(UNDERSCORE).or(NON_ASCII); + public static final IntPredicate NAME_CHAR = NAME_FIRST.or(DIGIT); + + public static boolean isShorthand(String selector) { + if (null == selector || selector.isEmpty()) { + throw new IllegalArgumentException("A JSONPath selector cannot be empty"); + } + + /* + * Accepted characters for shorthand paths: + * - 'a' through 'z' + * - 'A' through 'Z' + * - '0' through '9' + * - Underscore ('_') + * - any non-ASCII Unicode character + */ + return NAME_FIRST.test(selector.codePointAt(0)) && selector.codePoints().skip(1).allMatch(NAME_CHAR); + } } } diff --git a/src/test/java/com/networknt/schema/Issue687Test.java b/src/test/java/com/networknt/schema/Issue687Test.java index 814801ded..4f5c07cf6 100644 --- a/src/test/java/com/networknt/schema/Issue687Test.java +++ b/src/test/java/com/networknt/schema/Issue687Test.java @@ -34,10 +34,10 @@ public static Stream appendTokens() { Arguments.of(PathType.LEGACY, "$.foo", "b~ar", "$.foo.b~ar"), Arguments.of(PathType.LEGACY, "$.foo", "b/ar", "$.foo.b/ar"), Arguments.of(PathType.JSON_PATH, "$.foo", "bar", "$.foo.bar"), - Arguments.of(PathType.JSON_PATH, "$.foo", "b.ar", "$.foo[\"b.ar\"]"), - Arguments.of(PathType.JSON_PATH, "$.foo", "b~ar", "$.foo[\"b~ar\"]"), - Arguments.of(PathType.JSON_PATH, "$.foo", "b/ar", "$.foo[\"b/ar\"]"), - Arguments.of(PathType.JSON_PATH, "$", "\"", "$[\"\\\"\"]"), + Arguments.of(PathType.JSON_PATH, "$.foo", "b.ar", "$.foo['b.ar']"), + Arguments.of(PathType.JSON_PATH, "$.foo", "b~ar", "$.foo['b~ar']"), + Arguments.of(PathType.JSON_PATH, "$.foo", "b/ar", "$.foo['b/ar']"), + Arguments.of(PathType.JSON_PATH, "$", "'", "$['\'']"), Arguments.of(PathType.JSON_POINTER, "/foo", "bar", "/foo/bar"), Arguments.of(PathType.JSON_POINTER, "/foo", "b.ar", "/foo/b.ar"), Arguments.of(PathType.JSON_POINTER, "/foo", "b~ar", "/foo/b~0ar"), @@ -58,8 +58,8 @@ public static Stream validationMessages() { String content = "{ \"foo\": \"a\", \"b.ar\": 1, \"children\": [ { \"childFoo\": \"a\", \"c/hildBar\": 1 } ] }"; return Stream.of( Arguments.of(PathType.LEGACY, schemaPath, content, new String[] { "$.b.ar", "$.children[0].c/hildBar" }), - Arguments.of(PathType.JSON_PATH, schemaPath, content, new String[] { "$[\"b.ar\"]", "$.children[0][\"c/hildBar\"]" }), - Arguments.of(PathType.JSON_PATH, schemaPath, content, new String[] { "$[\"b.ar\"]", "$.children[0][\"c/hildBar\"]" }), + Arguments.of(PathType.JSON_PATH, schemaPath, content, new String[] { "$['b.ar']", "$.children[0]['c/hildBar']" }), + Arguments.of(PathType.JSON_PATH, schemaPath, content, new String[] { "$['b.ar']", "$.children[0]['c/hildBar']" }), Arguments.of(PathType.JSON_POINTER, schemaPath, content, new String[] { "/b.ar", "/children/0/c~1hildBar" }) ); } @@ -119,7 +119,7 @@ void testDoubleQuotes() throws JsonProcessingException { // {"\"": 1} Set validationMessages = schema.validate(mapper.readTree("{\"\\\"\": 1}")); assertEquals(1, validationMessages.size()); - assertEquals("$[\"\\\"\"]", validationMessages.iterator().next().getPath()); + assertEquals("$['\"']", validationMessages.iterator().next().getPath()); } } diff --git a/src/test/java/com/networknt/schema/PathTypeTest.java b/src/test/java/com/networknt/schema/PathTypeTest.java new file mode 100644 index 000000000..0f317e2b7 --- /dev/null +++ b/src/test/java/com/networknt/schema/PathTypeTest.java @@ -0,0 +1,49 @@ +package com.networknt.schema; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class PathTypeTest { + + @Test + void rejectNull() { + Assertions.assertThrowsExactly(IllegalArgumentException.class, () -> { + PathType.fromJsonPath(null); + }); + } + + @Test + void rejectEmptyString() { + Assertions.assertThrowsExactly(IllegalArgumentException.class, () -> { + PathType.fromJsonPath(""); + }); + } + + @Test + void acceptRoot() { + assertEquals("", PathType.fromJsonPath("$")); + } + + @Test + void acceptSimpleIndex() { + assertEquals("/0", PathType.fromJsonPath("$[0]")); + } + + @Test + void acceptSimpleProperty() { + assertEquals("/a", PathType.fromJsonPath("$.a")); + } + + @Test + void acceptEscapedProperty() { + assertEquals("/a", PathType.fromJsonPath("$['a']")); + } + + @Test + void hasSpecialCharacters() { + assertEquals("/a.b/c-d", PathType.fromJsonPath("$['a.b']['c-d']")); + } + +}