Skip to content

Commit a5e3571

Browse files
committed
Addressed #52
Schema validation errors now have new properties to locate the issue easier! // FieldName is the name of the specific field that failed validation (last segment of the path) FieldName string `json:"fieldName,omitempty" yaml:"fieldName,omitempty"` // FieldPath is the JSONPath representation of the field location (e.g., "$.user.email") FieldPath string `json:"fieldPath,omitempty" yaml:"fieldPath,omitempty"` // InstancePath is the raw path segments from the root to the failing field InstancePath []string `json:"instancePath,omitempty" yaml:"instancePath,omitempty"`
1 parent b122915 commit a5e3571

File tree

8 files changed

+364
-0
lines changed

8 files changed

+364
-0
lines changed

errors/validation_error.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ type SchemaValidationFailure struct {
1818
// Location is the XPath-like location of the validation failure
1919
Location string `json:"location,omitempty" yaml:"location,omitempty"`
2020

21+
// FieldName is the name of the specific field that failed validation (last segment of the path)
22+
FieldName string `json:"fieldName,omitempty" yaml:"fieldName,omitempty"`
23+
24+
// FieldPath is the JSONPath representation of the field location (e.g., "$.user.email")
25+
FieldPath string `json:"fieldPath,omitempty" yaml:"fieldPath,omitempty"`
26+
27+
// InstancePath is the raw path segments from the root to the failing field
28+
InstancePath []string `json:"instancePath,omitempty" yaml:"instancePath,omitempty"`
29+
2130
// DeepLocation is the path to the validation failure as exposed by the jsonschema library.
2231
DeepLocation string `json:"deepLocation,omitempty" yaml:"deepLocation,omitempty"`
2332

helpers/path_finder.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,70 @@ func ExtractJSONPathsFromValidationErrors(errors []*jsonschema.ValidationError)
9292
}
9393
return paths
9494
}
95+
96+
// ExtractFieldNameFromInstanceLocation returns the last segment of the instance location as the field name
97+
func ExtractFieldNameFromInstanceLocation(instanceLocation []string) string {
98+
if len(instanceLocation) == 0 {
99+
return ""
100+
}
101+
return instanceLocation[len(instanceLocation)-1]
102+
}
103+
104+
// ExtractFieldNameFromStringLocation returns the last segment of the instance location as the field name
105+
// when the location is provided as a string path
106+
func ExtractFieldNameFromStringLocation(instanceLocation string) string {
107+
if instanceLocation == "" {
108+
return ""
109+
}
110+
111+
// Handle string format like "/properties/email" or "/0/name"
112+
segments := strings.Split(strings.Trim(instanceLocation, "/"), "/")
113+
if len(segments) == 0 || (len(segments) == 1 && segments[0] == "") {
114+
return ""
115+
}
116+
117+
return segments[len(segments)-1]
118+
}
119+
120+
// ExtractJSONPathFromInstanceLocation creates a JSONPath string from instance location segments
121+
func ExtractJSONPathFromInstanceLocation(instanceLocation []string) string {
122+
if len(instanceLocation) == 0 {
123+
return ""
124+
}
125+
126+
var b strings.Builder
127+
b.WriteString("$")
128+
129+
for _, seg := range instanceLocation {
130+
switch {
131+
case isNumeric(seg):
132+
b.WriteString(fmt.Sprintf("[%s]", seg))
133+
134+
case isSimpleIdentifier(seg):
135+
b.WriteByte('.')
136+
b.WriteString(seg)
137+
138+
default:
139+
esc := escapeBracketString(seg)
140+
b.WriteString("['")
141+
b.WriteString(esc)
142+
b.WriteString("']")
143+
}
144+
}
145+
return b.String()
146+
}
147+
148+
// ExtractJSONPathFromStringLocation creates a JSONPath string from string-based instance location
149+
func ExtractJSONPathFromStringLocation(instanceLocation string) string {
150+
if instanceLocation == "" {
151+
return ""
152+
}
153+
154+
// Convert string format like "/properties/email" to array format
155+
segments := strings.Split(strings.Trim(instanceLocation, "/"), "/")
156+
if len(segments) == 0 || (len(segments) == 1 && segments[0] == "") {
157+
return ""
158+
}
159+
160+
return ExtractJSONPathFromInstanceLocation(segments)
161+
}

helpers/path_finder_test.go

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,3 +302,227 @@ func TestExtractJSONPathsFromValidationErrors(t *testing.T) {
302302
})
303303
}
304304
}
305+
306+
func TestExtractFieldNameFromInstanceLocation(t *testing.T) {
307+
testCases := []struct {
308+
name string
309+
instancePath []string
310+
expected string
311+
}{
312+
{
313+
name: "Empty path",
314+
instancePath: []string{},
315+
expected: "",
316+
},
317+
{
318+
name: "Single field",
319+
instancePath: []string{"name"},
320+
expected: "name",
321+
},
322+
{
323+
name: "Nested field",
324+
instancePath: []string{"user", "profile", "email"},
325+
expected: "email",
326+
},
327+
{
328+
name: "Array index",
329+
instancePath: []string{"users", "0", "name"},
330+
expected: "name",
331+
},
332+
{
333+
name: "Complex path",
334+
instancePath: []string{"root", "nested", "array", "1", "field"},
335+
expected: "field",
336+
},
337+
{
338+
name: "Field with special characters",
339+
instancePath: []string{"user", "email-address", "value"},
340+
expected: "value",
341+
},
342+
{
343+
name: "Numeric field name",
344+
instancePath: []string{"data", "123"},
345+
expected: "123",
346+
},
347+
}
348+
349+
for _, tc := range testCases {
350+
t.Run(tc.name, func(t *testing.T) {
351+
result := ExtractFieldNameFromInstanceLocation(tc.instancePath)
352+
assert.Equal(t, tc.expected, result)
353+
})
354+
}
355+
}
356+
357+
func TestExtractJSONPathFromInstanceLocation(t *testing.T) {
358+
testCases := []struct {
359+
name string
360+
instancePath []string
361+
expected string
362+
}{
363+
{
364+
name: "Empty path",
365+
instancePath: []string{},
366+
expected: "",
367+
},
368+
{
369+
name: "Simple field",
370+
instancePath: []string{"name"},
371+
expected: "$.name",
372+
},
373+
{
374+
name: "Nested object fields",
375+
instancePath: []string{"user", "profile", "email"},
376+
expected: "$.user.profile.email",
377+
},
378+
{
379+
name: "Array access",
380+
instancePath: []string{"users", "0", "name"},
381+
expected: "$.users[0].name",
382+
},
383+
{
384+
name: "Mixed array and object",
385+
instancePath: []string{"data", "items", "1", "properties", "value"},
386+
expected: "$.data.items[1].properties.value",
387+
},
388+
{
389+
name: "Field with dashes",
390+
instancePath: []string{"user", "email-address"},
391+
expected: "$.user['email-address']",
392+
},
393+
{
394+
name: "Field with spaces",
395+
instancePath: []string{"user", "full name"},
396+
expected: "$.user['full name']",
397+
},
398+
{
399+
name: "Field with special characters",
400+
instancePath: []string{"data", "field'with'quotes"},
401+
expected: "$.data['field\\'with\\'quotes']",
402+
},
403+
{
404+
name: "Field with backslash",
405+
instancePath: []string{"data", "field\\with\\backslash"},
406+
expected: "$.data['field\\\\with\\\\backslash']",
407+
},
408+
{
409+
name: "Unicode field name",
410+
instancePath: []string{"🙂", "unicode_field"},
411+
expected: "$['🙂'].unicode_field",
412+
},
413+
{
414+
name: "Numeric array indices",
415+
instancePath: []string{"matrix", "0", "1", "value"},
416+
expected: "$.matrix[0][1].value",
417+
},
418+
}
419+
420+
for _, tc := range testCases {
421+
t.Run(tc.name, func(t *testing.T) {
422+
result := ExtractJSONPathFromInstanceLocation(tc.instancePath)
423+
assert.Equal(t, tc.expected, result)
424+
})
425+
}
426+
}
427+
428+
func TestExtractFieldNameFromStringLocation(t *testing.T) {
429+
testCases := []struct {
430+
name string
431+
instancePath string
432+
expected string
433+
}{
434+
{
435+
name: "Empty path",
436+
instancePath: "",
437+
expected: "",
438+
},
439+
{
440+
name: "Single field",
441+
instancePath: "/name",
442+
expected: "name",
443+
},
444+
{
445+
name: "Nested field",
446+
instancePath: "/user/profile/email",
447+
expected: "email",
448+
},
449+
{
450+
name: "Array index",
451+
instancePath: "/users/0/name",
452+
expected: "name",
453+
},
454+
{
455+
name: "Complex path",
456+
instancePath: "/root/nested/array/1/field",
457+
expected: "field",
458+
},
459+
{
460+
name: "Field with special characters",
461+
instancePath: "/user/email-address/value",
462+
expected: "value",
463+
},
464+
{
465+
name: "Root path only",
466+
instancePath: "/",
467+
expected: "",
468+
},
469+
}
470+
471+
for _, tc := range testCases {
472+
t.Run(tc.name, func(t *testing.T) {
473+
result := ExtractFieldNameFromStringLocation(tc.instancePath)
474+
assert.Equal(t, tc.expected, result)
475+
})
476+
}
477+
}
478+
479+
func TestExtractJSONPathFromStringLocation(t *testing.T) {
480+
testCases := []struct {
481+
name string
482+
instancePath string
483+
expected string
484+
}{
485+
{
486+
name: "Empty path",
487+
instancePath: "",
488+
expected: "",
489+
},
490+
{
491+
name: "Simple field",
492+
instancePath: "/name",
493+
expected: "$.name",
494+
},
495+
{
496+
name: "Nested object fields",
497+
instancePath: "/user/profile/email",
498+
expected: "$.user.profile.email",
499+
},
500+
{
501+
name: "Array access",
502+
instancePath: "/users/0/name",
503+
expected: "$.users[0].name",
504+
},
505+
{
506+
name: "Mixed array and object",
507+
instancePath: "/data/items/1/properties/value",
508+
expected: "$.data.items[1].properties.value",
509+
},
510+
{
511+
name: "Root path only",
512+
instancePath: "/",
513+
expected: "",
514+
},
515+
{
516+
name: "Complex nested path",
517+
instancePath: "/matrix/0/1/value",
518+
expected: "$.matrix[0][1].value",
519+
},
520+
}
521+
522+
for _, tc := range testCases {
523+
t.Run(tc.name, func(t *testing.T) {
524+
result := ExtractJSONPathFromStringLocation(tc.instancePath)
525+
assert.Equal(t, tc.expected, result)
526+
})
527+
}
528+
}

parameters/validate_parameter.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,21 @@ func formatJsonSchemaValidationError(schema *base.Schema, scErrs *jsonschema.Val
225225
continue // ignore this error, it's not useful
226226
}
227227

228+
// Convert string location to path segments for InstancePath
229+
var instancePathSegments []string
230+
if er.InstanceLocation != "" {
231+
instancePathSegments = strings.Split(strings.Trim(er.InstanceLocation, "/"), "/")
232+
if len(instancePathSegments) == 1 && instancePathSegments[0] == "" {
233+
instancePathSegments = []string{}
234+
}
235+
}
236+
228237
fail := &errors.SchemaValidationFailure{
229238
Reason: errMsg,
230239
Location: er.KeywordLocation,
240+
FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation),
241+
FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation),
242+
InstancePath: instancePathSegments,
231243
OriginalError: scErrs,
232244
}
233245
if schema != nil {

requests/validate_request.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"reflect"
1313
"regexp"
1414
"strconv"
15+
"strings"
1516

1617
"github.com/pb33f/libopenapi/datamodel/high/base"
1718
"github.com/santhosh-tekuri/jsonschema/v6"
@@ -166,9 +167,21 @@ func ValidateRequestSchema(
166167

167168
errMsg := er.Error.Kind.LocalizedString(message.NewPrinter(language.Tag{}))
168169

170+
// Convert string location to path segments for InstancePath
171+
var instancePathSegments []string
172+
if er.InstanceLocation != "" {
173+
instancePathSegments = strings.Split(strings.Trim(er.InstanceLocation, "/"), "/")
174+
if len(instancePathSegments) == 1 && instancePathSegments[0] == "" {
175+
instancePathSegments = []string{}
176+
}
177+
}
178+
169179
violation := &errors.SchemaValidationFailure{
170180
Reason: errMsg,
171181
Location: er.KeywordLocation,
182+
FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation),
183+
FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation),
184+
InstancePath: instancePathSegments,
172185
ReferenceSchema: string(renderedSchema),
173186
ReferenceObject: referenceObject,
174187
OriginalError: jk,

responses/validate_response.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"reflect"
1313
"regexp"
1414
"strconv"
15+
"strings"
1516

1617
"github.com/pb33f/libopenapi/datamodel/high/base"
1718
"github.com/santhosh-tekuri/jsonschema/v6"
@@ -193,9 +194,21 @@ func ValidateResponseSchema(
193194
referenceObject = string(responseBody)
194195
}
195196

197+
// Convert string location to path segments for InstancePath
198+
var instancePathSegments []string
199+
if er.InstanceLocation != "" {
200+
instancePathSegments = strings.Split(strings.Trim(er.InstanceLocation, "/"), "/")
201+
if len(instancePathSegments) == 1 && instancePathSegments[0] == "" {
202+
instancePathSegments = []string{}
203+
}
204+
}
205+
196206
violation := &errors.SchemaValidationFailure{
197207
Reason: errMsg,
198208
Location: er.KeywordLocation,
209+
FieldName: helpers.ExtractFieldNameFromStringLocation(er.InstanceLocation),
210+
FieldPath: helpers.ExtractJSONPathFromStringLocation(er.InstanceLocation),
211+
InstancePath: instancePathSegments,
199212
ReferenceSchema: string(renderedSchema),
200213
ReferenceObject: referenceObject,
201214
OriginalError: jk,

0 commit comments

Comments
 (0)