Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 145 additions & 18 deletions src/main/java/com/networknt/schema/PathType.java
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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);
}
}
}
14 changes: 7 additions & 7 deletions src/test/java/com/networknt/schema/Issue687Test.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ public static Stream<Arguments> 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"),
Expand All @@ -58,8 +58,8 @@ public static Stream<Arguments> 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" })
);
}
Expand Down Expand Up @@ -119,7 +119,7 @@ void testDoubleQuotes() throws JsonProcessingException {
// {"\"": 1}
Set<ValidationMessage> validationMessages = schema.validate(mapper.readTree("{\"\\\"\": 1}"));
assertEquals(1, validationMessages.size());
assertEquals("$[\"\\\"\"]", validationMessages.iterator().next().getPath());
assertEquals("$['\"']", validationMessages.iterator().next().getPath());
}

}
49 changes: 49 additions & 0 deletions src/test/java/com/networknt/schema/PathTypeTest.java
Original file line number Diff line number Diff line change
@@ -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']"));
}

}