Skip to content

Commit 2143b54

Browse files
fduttonFaron Dutton
and
Faron Dutton
authored
Resolves incomplete validation of unevaluatedProperties. (#754)
Resolves #752 Co-authored-by: Faron Dutton <[email protected]>
1 parent 8a4cffa commit 2143b54

File tree

3 files changed

+201
-25
lines changed

3 files changed

+201
-25
lines changed

src/main/java/com/networknt/schema/PathType.java

Lines changed: 145 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.networknt.schema;
22

33
import java.util.function.Function;
4+
import java.util.function.IntPredicate;
45

56
/**
67
* Enumeration defining the different approached available to generate the paths added to validation messages.
@@ -11,27 +12,38 @@ public enum PathType {
1112
* The legacy approach, loosely based on JSONPath (but not guaranteed to give valid JSONPath expressions).
1213
*/
1314
LEGACY("$", (token) -> "." + token, (index) -> "[" + index + "]"),
15+
1416
/**
1517
* Paths as JSONPath expressions.
1618
*/
1719
JSON_PATH("$", (token) -> {
20+
21+
if (token.isEmpty()) {
22+
throw new IllegalArgumentException("A JSONPath selector cannot be empty");
23+
}
24+
25+
String t = token;
1826
/*
1927
* Accepted characters for shorthand paths:
2028
* - 'a' through 'z'
2129
* - 'A' through 'Z'
2230
* - '0' through '9'
2331
* - Underscore ('_')
32+
* - any non-ASCII Unicode character
2433
*/
25-
if (token.codePoints().allMatch(c -> (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_')) {
26-
return "." + token;
27-
} else {
28-
if (token.indexOf('\"') != -1) {
29-
// Make sure also any double quotes are escaped.
30-
token = token.replace("\"", "\\\"");
31-
}
32-
return "[\"" + token + "\"]";
34+
if (JSONPath.isShorthand(t)) {
35+
return "." + t;
3336
}
37+
38+
boolean containsApostrophe = 0 <= t.indexOf('\'');
39+
if (containsApostrophe) {
40+
// Make sure also any apostrophes are escaped.
41+
t = t.replace("'", "\\'");
42+
}
43+
44+
return "['" + token + "']";
3445
}, (index) -> "[" + index + "]"),
46+
3547
/**
3648
* Paths as JSONPointer expressions.
3749
*/
@@ -77,7 +89,7 @@ public enum PathType {
7789
* @return The resulting complete path.
7890
*/
7991
public String append(String currentPath, String child) {
80-
return currentPath + appendTokenFn.apply(child);
92+
return currentPath + this.appendTokenFn.apply(child);
8193
}
8294

8395
/**
@@ -88,7 +100,7 @@ public String append(String currentPath, String child) {
88100
* @return The resulting complete path.
89101
*/
90102
public String append(String currentPath, int index) {
91-
return currentPath + appendIndexFn.apply(index);
103+
return currentPath + this.appendIndexFn.apply(index);
92104
}
93105

94106
/**
@@ -97,22 +109,137 @@ public String append(String currentPath, int index) {
97109
* @return The root token.
98110
*/
99111
public String getRoot() {
100-
return rootToken;
112+
return this.rootToken;
101113
}
102114

103115
public String convertToJsonPointer(String path) {
104116
switch (this) {
105117
case JSON_POINTER: return path;
106-
default: return fromLegacyOrJsonPath(path);
118+
case JSON_PATH: return fromJsonPath(path);
119+
default: return fromLegacy(path);
107120
}
108121
}
109122

110-
static String fromLegacyOrJsonPath(String path) {
123+
static String fromLegacy(String path) {
111124
return path
112-
.replace("\"", "")
113-
.replace("]", "")
114-
.replace('[', '/')
115-
.replace('.', '/')
116-
.replace("$", "");
125+
.replace("\"", "")
126+
.replace("]", "")
127+
.replace('[', '/')
128+
.replace('.', '/')
129+
.replace("$", "");
130+
}
131+
132+
static String fromJsonPath(String str) {
133+
if (null == str || str.isEmpty() || '$' != str.charAt(0)) {
134+
throw new IllegalArgumentException("JSON Path must start with '$'");
135+
}
136+
137+
String tail = str.substring(1);
138+
if (tail.isEmpty()) {
139+
return "";
140+
}
141+
142+
int len = tail.length();
143+
StringBuilder sb = new StringBuilder(len);
144+
for (int i = 0; i < len;) {
145+
char c = tail.charAt(i);
146+
switch (c) {
147+
case '.': sb.append('/'); i = parseShorthand(sb, tail, i + 1); break;
148+
case '[': sb.append('/'); i = parseSelector(sb, tail, i + 1); break;
149+
default: throw new IllegalArgumentException("JSONPath must reference a property or array index");
150+
}
151+
}
152+
return sb.toString();
153+
}
154+
155+
/**
156+
* Parses a JSONPath shorthand selector
157+
* @param sb receives the result
158+
* @param s the source string
159+
* @param pos the index into s immediately following the dot
160+
* @return the index following the selector name
161+
*/
162+
static int parseShorthand(StringBuilder sb, String s, int pos) {
163+
int len = s.length();
164+
int i = pos;
165+
for (; i < len; ++i) {
166+
char c = s.charAt(i);
167+
switch (c) {
168+
case '.':
169+
case '[':
170+
break;
171+
default:
172+
sb.append(c);
173+
break;
174+
}
175+
}
176+
return i;
177+
}
178+
179+
/**
180+
* Parses a JSONPath selector
181+
* @param sb receives the result
182+
* @param s the source string
183+
* @param pos the index into s immediately following the open bracket
184+
* @return the index following the closing bracket
185+
*/
186+
static int parseSelector(StringBuilder sb, String s, int pos) {
187+
int close = s.indexOf(']', pos);
188+
if (-1 == close) {
189+
throw new IllegalArgumentException("JSONPath contains an unterminated selector");
190+
}
191+
192+
if ('\'' == s.charAt(pos)) {
193+
parseQuote(sb, s, pos + 1);
194+
} else {
195+
sb.append(s.substring(pos, close));
196+
}
197+
198+
return close + 1;
199+
}
200+
201+
/**
202+
* Parses a single-quoted string.
203+
* @param sb receives the result
204+
* @param s the source string
205+
* @param pos the index into s immediately following the open quote
206+
* @return the index following the closing quote
207+
*/
208+
static int parseQuote(StringBuilder sb, String s, int pos) {
209+
int close = pos;
210+
do {
211+
close = s.indexOf('\'', close);
212+
if (-1 == close) {
213+
throw new IllegalArgumentException("JSONPath contains an unterminated quoted string");
214+
}
215+
} while ('\\' == s.charAt(close - 1)) ;
216+
sb.append(s.substring(pos, close));
217+
return close + 1;
218+
}
219+
220+
static class JSONPath {
221+
public static final IntPredicate ALPHA = c -> (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
222+
public static final IntPredicate DIGIT = c -> c >= '0' && c <= '9';
223+
public static final IntPredicate NON_ASCII = c -> (c >= 0x80 && c <= 0x10FFFF);
224+
public static final IntPredicate UNDERSCORE = c -> '_' == c;
225+
226+
public static final IntPredicate NAME_FIRST = ALPHA.or(UNDERSCORE).or(NON_ASCII);
227+
public static final IntPredicate NAME_CHAR = NAME_FIRST.or(DIGIT);
228+
229+
public static boolean isShorthand(String selector) {
230+
if (null == selector || selector.isEmpty()) {
231+
throw new IllegalArgumentException("A JSONPath selector cannot be empty");
232+
}
233+
234+
/*
235+
* Accepted characters for shorthand paths:
236+
* - 'a' through 'z'
237+
* - 'A' through 'Z'
238+
* - '0' through '9'
239+
* - Underscore ('_')
240+
* - any non-ASCII Unicode character
241+
*/
242+
return NAME_FIRST.test(selector.codePointAt(0)) && selector.codePoints().skip(1).allMatch(NAME_CHAR);
243+
}
117244
}
118245
}

src/test/java/com/networknt/schema/Issue687Test.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ public static Stream<Arguments> appendTokens() {
3434
Arguments.of(PathType.LEGACY, "$.foo", "b~ar", "$.foo.b~ar"),
3535
Arguments.of(PathType.LEGACY, "$.foo", "b/ar", "$.foo.b/ar"),
3636
Arguments.of(PathType.JSON_PATH, "$.foo", "bar", "$.foo.bar"),
37-
Arguments.of(PathType.JSON_PATH, "$.foo", "b.ar", "$.foo[\"b.ar\"]"),
38-
Arguments.of(PathType.JSON_PATH, "$.foo", "b~ar", "$.foo[\"b~ar\"]"),
39-
Arguments.of(PathType.JSON_PATH, "$.foo", "b/ar", "$.foo[\"b/ar\"]"),
40-
Arguments.of(PathType.JSON_PATH, "$", "\"", "$[\"\\\"\"]"),
37+
Arguments.of(PathType.JSON_PATH, "$.foo", "b.ar", "$.foo['b.ar']"),
38+
Arguments.of(PathType.JSON_PATH, "$.foo", "b~ar", "$.foo['b~ar']"),
39+
Arguments.of(PathType.JSON_PATH, "$.foo", "b/ar", "$.foo['b/ar']"),
40+
Arguments.of(PathType.JSON_PATH, "$", "'", "$['\'']"),
4141
Arguments.of(PathType.JSON_POINTER, "/foo", "bar", "/foo/bar"),
4242
Arguments.of(PathType.JSON_POINTER, "/foo", "b.ar", "/foo/b.ar"),
4343
Arguments.of(PathType.JSON_POINTER, "/foo", "b~ar", "/foo/b~0ar"),
@@ -58,8 +58,8 @@ public static Stream<Arguments> validationMessages() {
5858
String content = "{ \"foo\": \"a\", \"b.ar\": 1, \"children\": [ { \"childFoo\": \"a\", \"c/hildBar\": 1 } ] }";
5959
return Stream.of(
6060
Arguments.of(PathType.LEGACY, schemaPath, content, new String[] { "$.b.ar", "$.children[0].c/hildBar" }),
61-
Arguments.of(PathType.JSON_PATH, schemaPath, content, new String[] { "$[\"b.ar\"]", "$.children[0][\"c/hildBar\"]" }),
62-
Arguments.of(PathType.JSON_PATH, schemaPath, content, new String[] { "$[\"b.ar\"]", "$.children[0][\"c/hildBar\"]" }),
61+
Arguments.of(PathType.JSON_PATH, schemaPath, content, new String[] { "$['b.ar']", "$.children[0]['c/hildBar']" }),
62+
Arguments.of(PathType.JSON_PATH, schemaPath, content, new String[] { "$['b.ar']", "$.children[0]['c/hildBar']" }),
6363
Arguments.of(PathType.JSON_POINTER, schemaPath, content, new String[] { "/b.ar", "/children/0/c~1hildBar" })
6464
);
6565
}
@@ -119,7 +119,7 @@ void testDoubleQuotes() throws JsonProcessingException {
119119
// {"\"": 1}
120120
Set<ValidationMessage> validationMessages = schema.validate(mapper.readTree("{\"\\\"\": 1}"));
121121
assertEquals(1, validationMessages.size());
122-
assertEquals("$[\"\\\"\"]", validationMessages.iterator().next().getPath());
122+
assertEquals("$['\"']", validationMessages.iterator().next().getPath());
123123
}
124124

125125
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.networknt.schema;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import org.junit.jupiter.api.Assertions;
6+
import org.junit.jupiter.api.Test;
7+
8+
class PathTypeTest {
9+
10+
@Test
11+
void rejectNull() {
12+
Assertions.assertThrowsExactly(IllegalArgumentException.class, () -> {
13+
PathType.fromJsonPath(null);
14+
});
15+
}
16+
17+
@Test
18+
void rejectEmptyString() {
19+
Assertions.assertThrowsExactly(IllegalArgumentException.class, () -> {
20+
PathType.fromJsonPath("");
21+
});
22+
}
23+
24+
@Test
25+
void acceptRoot() {
26+
assertEquals("", PathType.fromJsonPath("$"));
27+
}
28+
29+
@Test
30+
void acceptSimpleIndex() {
31+
assertEquals("/0", PathType.fromJsonPath("$[0]"));
32+
}
33+
34+
@Test
35+
void acceptSimpleProperty() {
36+
assertEquals("/a", PathType.fromJsonPath("$.a"));
37+
}
38+
39+
@Test
40+
void acceptEscapedProperty() {
41+
assertEquals("/a", PathType.fromJsonPath("$['a']"));
42+
}
43+
44+
@Test
45+
void hasSpecialCharacters() {
46+
assertEquals("/a.b/c-d", PathType.fromJsonPath("$['a.b']['c-d']"));
47+
}
48+
49+
}

0 commit comments

Comments
 (0)