Skip to content

Commit c1bd7d1

Browse files
committed
Added a new helper function for #120
Two new helper functions for #120 in the `helpers` module. `ExtractJSONPathFromValidationError(e *jsonschema.ValidationError) string` and `ExtractJSONPathsFromValidationErrors(errors []*jsonschema.ValidationError) []string`
1 parent 1b10bc1 commit c1bd7d1

File tree

2 files changed

+396
-0
lines changed

2 files changed

+396
-0
lines changed

helpers/path_finder.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley
2+
// https://pb33f.io
3+
4+
package helpers
5+
6+
import (
7+
"fmt"
8+
"github.com/santhosh-tekuri/jsonschema/v6"
9+
"strings"
10+
"unicode"
11+
)
12+
13+
// ExtractJSONPathFromValidationError traverses and processes a ValidationError to construct a JSONPath string representation of its instance location.
14+
func ExtractJSONPathFromValidationError(e *jsonschema.ValidationError) string {
15+
if e.Causes != nil && len(e.Causes) > 0 {
16+
for _, cause := range e.Causes {
17+
ExtractJSONPathFromValidationError(cause)
18+
}
19+
}
20+
21+
if len(e.InstanceLocation) > 0 {
22+
23+
var b strings.Builder
24+
b.WriteString("$")
25+
26+
for _, seg := range e.InstanceLocation {
27+
switch {
28+
case isNumeric(seg):
29+
b.WriteString(fmt.Sprintf("[%s]", seg))
30+
31+
case isSimpleIdentifier(seg):
32+
b.WriteByte('.')
33+
b.WriteString(seg)
34+
35+
default:
36+
esc := escapeBracketString(seg)
37+
b.WriteString("['")
38+
b.WriteString(esc)
39+
b.WriteString("']")
40+
}
41+
}
42+
return b.String()
43+
}
44+
return ""
45+
}
46+
47+
// isNumeric returns true if s is a non‐empty string of digits.
48+
func isNumeric(s string) bool {
49+
if s == "" {
50+
return false
51+
}
52+
for _, r := range s {
53+
if r < '0' || r > '9' {
54+
return false
55+
}
56+
}
57+
return true
58+
}
59+
60+
// isSimpleIdentifier returns true if s matches [A-Za-z_][A-Za-z0-9_]*.
61+
func isSimpleIdentifier(s string) bool {
62+
for i, r := range s {
63+
if i == 0 {
64+
if !unicode.IsLetter(r) && r != '_' {
65+
return false
66+
}
67+
} else {
68+
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' {
69+
return false
70+
}
71+
}
72+
}
73+
return len(s) > 0
74+
}
75+
76+
// escapeBracketString escapes backslashes and single‐quotes for inside ['...']
77+
func escapeBracketString(s string) string {
78+
s = strings.ReplaceAll(s, `\`, `\\`)
79+
s = strings.ReplaceAll(s, `'`, `\'`)
80+
return s
81+
}
82+
83+
// ExtractJSONPathsFromValidationErrors takes a slice of ValidationError pointers and returns a slice of JSONPath strings
84+
func ExtractJSONPathsFromValidationErrors(errors []*jsonschema.ValidationError) []string {
85+
var paths []string
86+
for _, err := range errors {
87+
path := ExtractJSONPathFromValidationError(err)
88+
if path != "" {
89+
paths = append(paths, path)
90+
}
91+
}
92+
return paths
93+
}

helpers/path_finder_test.go

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
// Copyright 2023-2025 Princess Beef Heavy Industries, LLC / Dave Shanley
2+
// https://pb33f.io
3+
4+
package helpers
5+
6+
import (
7+
"github.com/santhosh-tekuri/jsonschema/v6"
8+
"github.com/stretchr/testify/assert"
9+
"testing"
10+
)
11+
12+
func TestDiveIntoValidationError(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
error *jsonschema.ValidationError
16+
expected string
17+
}{
18+
{
19+
name: "empty instance location",
20+
error: &jsonschema.ValidationError{
21+
InstanceLocation: []string{},
22+
},
23+
expected: "",
24+
},
25+
{
26+
name: "numeric path segments",
27+
error: &jsonschema.ValidationError{
28+
InstanceLocation: []string{"root", "array", "0", "1"},
29+
},
30+
expected: "$.root.array[0][1]",
31+
},
32+
{
33+
name: "simple identifier path segments",
34+
error: &jsonschema.ValidationError{
35+
InstanceLocation: []string{"user", "name", "first"},
36+
},
37+
expected: "$.user.name.first",
38+
},
39+
{
40+
name: "complex path segments requiring escaping",
41+
error: &jsonschema.ValidationError{
42+
InstanceLocation: []string{"user", "name-with-dash", "special'quote", "back\\slash"},
43+
},
44+
expected: "$.user['name-with-dash']['special\\'quote']['back\\\\slash']",
45+
},
46+
{
47+
name: "mixed path segments",
48+
error: &jsonschema.ValidationError{
49+
InstanceLocation: []string{"users", "0", "address", "street-name", "123"},
50+
},
51+
expected: "$.users[0].address['street-name'][123]",
52+
},
53+
{
54+
name: "with nested causes",
55+
error: &jsonschema.ValidationError{
56+
InstanceLocation: []string{"root"},
57+
Causes: []*jsonschema.ValidationError{
58+
{
59+
InstanceLocation: []string{"nested", "error"},
60+
},
61+
},
62+
},
63+
expected: "$.root",
64+
},
65+
}
66+
67+
for _, tt := range tests {
68+
t.Run(tt.name, func(t *testing.T) {
69+
result := ExtractJSONPathFromValidationError(tt.error)
70+
assert.Equal(t, tt.expected, result)
71+
})
72+
}
73+
}
74+
75+
func TestIsNumeric(t *testing.T) {
76+
tests := []struct {
77+
input string
78+
expected bool
79+
}{
80+
{"123", true},
81+
{"0", true},
82+
{"01", true},
83+
{"", false},
84+
{"abc", false},
85+
{"123abc", false},
86+
{"12.3", false},
87+
{"-123", false},
88+
}
89+
90+
for _, tt := range tests {
91+
t.Run(tt.input, func(t *testing.T) {
92+
result := isNumeric(tt.input)
93+
assert.Equal(t, tt.expected, result)
94+
})
95+
}
96+
}
97+
98+
func TestIsSimpleIdentifier(t *testing.T) {
99+
tests := []struct {
100+
input string
101+
expected bool
102+
}{
103+
{"abc", true},
104+
{"a123", true},
105+
{"_abc", true},
106+
{"_123", true},
107+
{"abc_123", true},
108+
{"", false},
109+
{"123abc", false},
110+
{"abc-def", false},
111+
{"abc.def", false},
112+
{"abc def", false},
113+
}
114+
115+
for _, tt := range tests {
116+
t.Run(tt.input, func(t *testing.T) {
117+
result := isSimpleIdentifier(tt.input)
118+
assert.Equal(t, tt.expected, result)
119+
})
120+
}
121+
}
122+
123+
func TestEscapeBracketString(t *testing.T) {
124+
tests := []struct {
125+
input string
126+
expected string
127+
}{
128+
{"normal", "normal"},
129+
{"with'quote", "with\\'quote"},
130+
{"with\\backslash", "with\\\\backslash"},
131+
{"with'quote\\and\\backslash", "with\\'quote\\\\and\\\\backslash"},
132+
{"", ""},
133+
}
134+
135+
for _, tt := range tests {
136+
t.Run(tt.input, func(t *testing.T) {
137+
result := escapeBracketString(tt.input)
138+
assert.Equal(t, tt.expected, result)
139+
})
140+
}
141+
}
142+
143+
// TestDiveIntoValidationErrorRecursion tests that the function properly handles
144+
// recursive traversal through nested validation errors.
145+
func TestDiveIntoValidationErrorRecursion(t *testing.T) {
146+
childError1 := &jsonschema.ValidationError{
147+
InstanceLocation: []string{"child1", "prop"},
148+
}
149+
150+
childError2 := &jsonschema.ValidationError{
151+
InstanceLocation: []string{"child2", "0", "name"},
152+
}
153+
154+
parentError := &jsonschema.ValidationError{
155+
InstanceLocation: []string{"parent"},
156+
Causes: []*jsonschema.ValidationError{childError1, childError2},
157+
}
158+
159+
// The parent error should return its own path
160+
result := ExtractJSONPathFromValidationError(parentError)
161+
assert.Equal(t, "$.parent", result)
162+
163+
// Verify the child errors return their paths correctly when called directly
164+
assert.Equal(t, "$.child1.prop", ExtractJSONPathFromValidationError(childError1))
165+
assert.Equal(t, "$.child2[0].name", ExtractJSONPathFromValidationError(childError2))
166+
}
167+
168+
// TestDiveIntoValidationErrorEdgeCases tests edge cases including empty strings and unusual characters
169+
func TestDiveIntoValidationErrorEdgeCases(t *testing.T) {
170+
tests := []struct {
171+
name string
172+
error *jsonschema.ValidationError
173+
expected string
174+
}{
175+
{
176+
name: "empty strings as elements",
177+
error: &jsonschema.ValidationError{
178+
InstanceLocation: []string{"", "property"},
179+
},
180+
expected: "$[''].property",
181+
},
182+
{
183+
name: "Unicode characters",
184+
error: &jsonschema.ValidationError{
185+
InstanceLocation: []string{"🙂", "unicode_property"},
186+
},
187+
expected: "$['🙂'].unicode_property",
188+
},
189+
{
190+
name: "null causes",
191+
error: &jsonschema.ValidationError{
192+
InstanceLocation: []string{"root"},
193+
Causes: nil,
194+
},
195+
expected: "$.root",
196+
},
197+
}
198+
199+
for _, tt := range tests {
200+
t.Run(tt.name, func(t *testing.T) {
201+
result := ExtractJSONPathFromValidationError(tt.error)
202+
assert.Equal(t, tt.expected, result)
203+
})
204+
}
205+
}
206+
207+
// TestExtractJSONPathsFromValidationErrors tests the ExtractJSONPathsFromValidationErrors function
208+
func TestExtractJSONPathsFromValidationErrors(t *testing.T) {
209+
tests := []struct {
210+
name string
211+
errors []*jsonschema.ValidationError
212+
expected []string
213+
}{
214+
{
215+
name: "nil errors",
216+
errors: nil,
217+
expected: nil,
218+
},
219+
{
220+
name: "empty errors",
221+
errors: []*jsonschema.ValidationError{},
222+
expected: nil,
223+
},
224+
{
225+
name: "single error with empty path",
226+
errors: []*jsonschema.ValidationError{
227+
{
228+
InstanceLocation: []string{},
229+
},
230+
},
231+
expected: nil,
232+
},
233+
{
234+
name: "single error with path",
235+
errors: []*jsonschema.ValidationError{
236+
{
237+
InstanceLocation: []string{"root", "property"},
238+
},
239+
},
240+
expected: []string{"$.root.property"},
241+
},
242+
{
243+
name: "multiple errors with paths",
244+
errors: []*jsonschema.ValidationError{
245+
{
246+
InstanceLocation: []string{"users", "0", "name"},
247+
},
248+
{
249+
InstanceLocation: []string{"users", "1", "address", "street"},
250+
},
251+
},
252+
expected: []string{"$.users[0].name", "$.users[1].address.street"},
253+
},
254+
{
255+
name: "mixed errors - some with empty paths",
256+
errors: []*jsonschema.ValidationError{
257+
{
258+
InstanceLocation: []string{},
259+
},
260+
{
261+
InstanceLocation: []string{"users", "0", "name"},
262+
},
263+
{
264+
InstanceLocation: []string{},
265+
},
266+
},
267+
expected: []string{"$.users[0].name"},
268+
},
269+
{
270+
name: "complex paths with special characters",
271+
errors: []*jsonschema.ValidationError{
272+
{
273+
InstanceLocation: []string{"data", "special-field", "nested"},
274+
},
275+
{
276+
InstanceLocation: []string{"data", "array", "0", "item's", "property"},
277+
},
278+
},
279+
expected: []string{"$.data['special-field'].nested", "$.data.array[0]['item\\'s'].property"},
280+
},
281+
{
282+
name: "with nested causes",
283+
errors: []*jsonschema.ValidationError{
284+
{
285+
InstanceLocation: []string{"parent"},
286+
Causes: []*jsonschema.ValidationError{
287+
{
288+
InstanceLocation: []string{"child", "property"},
289+
},
290+
},
291+
},
292+
},
293+
expected: []string{"$.parent"},
294+
},
295+
}
296+
297+
for _, tt := range tests {
298+
t.Run(tt.name, func(t *testing.T) {
299+
result := ExtractJSONPathsFromValidationErrors(tt.errors)
300+
assert.Equal(t, tt.expected, result)
301+
})
302+
}
303+
}

0 commit comments

Comments
 (0)