diff --git a/_examples/path_serialization/README.md b/_examples/path_serialization/README.md new file mode 100644 index 0000000..01e3970 --- /dev/null +++ b/_examples/path_serialization/README.md @@ -0,0 +1,29 @@ +# Path Serialization Example + +This example demonstrates how the same validation error can be serialized using different path formats. + +## Running the Example + +```bash +go run _examples/path_serialization/app.go +``` + +## What It Shows + +The example creates validation errors with various path structures and displays how each path is represented in different serialization formats: + +1. **Default Format**: The original format used by `Path()` method (`/segment1/segment2` or `0/1`) +2. **JSON Pointer (RFC 6901)**: Standard JSON Pointer format (`/segment1/segment2/0`) +3. **JSONPath**: JSONPath format (`$.segment1.segment2[0]`) +4. **Dot Notation**: Dot notation format (`segment1.segment2[0]`) + +## Example Output + +The program demonstrates: +- Simple nested paths (e.g., `users.profile.name`) +- Paths with array indices (e.g., `users[0].emails[1]`) +- Complex mixed paths (e.g., `data.items[2].metadata.tags[0]`) +- Paths starting with indices (e.g., `[5].value`) +- Single segment paths (e.g., `username`) + +Each example shows how the same error path is represented across all four serialization formats. diff --git a/_examples/path_serialization/app.go b/_examples/path_serialization/app.go new file mode 100644 index 0000000..698416a --- /dev/null +++ b/_examples/path_serialization/app.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "fmt" + + "proto.zip/studio/validate/pkg/errors" + "proto.zip/studio/validate/pkg/rulecontext" +) + +func main() { + fmt.Println("=== Path Serialization Examples ===\n") + + // Example 1: Simple nested path + fmt.Println("Example 1: Simple nested path (users.profile.name)") + example1() + + // Example 2: Path with array indices + fmt.Println("\nExample 2: Path with array indices (users[0].emails[1])") + example2() + + // Example 3: Complex mixed path + fmt.Println("\nExample 3: Complex mixed path (data.items[2].metadata.tags[0])") + example3() + + // Example 4: Path starting with index + fmt.Println("\nExample 4: Path starting with index ([5].value)") + example4() + + // Example 5: Single segment + fmt.Println("\nExample 5: Single segment (username)") + example5() +} + +func example1() { + ctx := rulecontext.WithPathString(context.Background(), "users") + ctx = rulecontext.WithPathString(ctx, "profile") + ctx = rulecontext.WithPathString(ctx, "name") + err := errors.Errorf(errors.CodeMin, ctx, "below minimum", "must be at least %d", 10) + + printAllFormats(err) +} + +func example2() { + ctx := rulecontext.WithPathString(context.Background(), "users") + ctx = rulecontext.WithPathIndex(ctx, 0) + ctx = rulecontext.WithPathString(ctx, "emails") + ctx = rulecontext.WithPathIndex(ctx, 1) + err := errors.Errorf(errors.CodeMin, ctx, "below minimum", "must be at least %d", 10) + + printAllFormats(err) +} + +func example3() { + ctx := rulecontext.WithPathString(context.Background(), "data") + ctx = rulecontext.WithPathString(ctx, "items") + ctx = rulecontext.WithPathIndex(ctx, 2) + ctx = rulecontext.WithPathString(ctx, "metadata") + ctx = rulecontext.WithPathString(ctx, "tags") + ctx = rulecontext.WithPathIndex(ctx, 0) + err := errors.Errorf(errors.CodeMin, ctx, "below minimum", "must be at least %d", 10) + + printAllFormats(err) +} + +func example4() { + ctx := rulecontext.WithPathIndex(context.Background(), 5) + ctx = rulecontext.WithPathString(ctx, "value") + err := errors.Errorf(errors.CodeMin, ctx, "below minimum", "must be at least %d", 10) + + printAllFormats(err) +} + +func example5() { + ctx := rulecontext.WithPathString(context.Background(), "username") + err := errors.Errorf(errors.CodeMin, ctx, "below minimum", "must be at least %d", 10) + + printAllFormats(err) +} + +func printAllFormats(err errors.ValidationError) { + defaultSerializer := errors.DefaultPathSerializer{} + jsonPointerSerializer := errors.JSONPointerSerializer{} + jsonPathSerializer := errors.JSONPathSerializer{} + dotNotationSerializer := errors.DotNotationSerializer{} + + fmt.Printf(" Default (Path()): %s\n", err.Path()) + fmt.Printf(" Default (PathAs): %s\n", err.PathAs(defaultSerializer)) + fmt.Printf(" JSON Pointer (RFC 6901): %s\n", err.PathAs(jsonPointerSerializer)) + fmt.Printf(" JSONPath: %s\n", err.PathAs(jsonPathSerializer)) + fmt.Printf(" Dot Notation: %s\n", err.PathAs(dotNotationSerializer)) +} diff --git a/pkg/errors/collection.go b/pkg/errors/collection.go index 329cf6a..9b76059 100644 --- a/pkg/errors/collection.go +++ b/pkg/errors/collection.go @@ -96,6 +96,27 @@ func (collection ValidationErrorCollection) For(path string) ValidationErrorColl return Collection(filteredErrors...) } +// ForPathAs returns a new collection containing only errors for a specific path +// using the provided serializer to compare paths. +func (collection ValidationErrorCollection) ForPathAs(path string, serializer PathSerializer) ValidationErrorCollection { + if len(collection) == 0 { + return nil + } + + var filteredErrors []ValidationError + for _, err := range collection { + if err.PathAs(serializer) == path { + filteredErrors = append(filteredErrors, err) + } + } + + if len(filteredErrors) == 0 { + return nil + } + + return Collection(filteredErrors...) +} + // Internal returns true if any error in the collection is an internal error. // Internal errors are the most general classification and take precedence. // Returns false for empty collections. diff --git a/pkg/errors/for_path_as_test.go b/pkg/errors/for_path_as_test.go new file mode 100644 index 0000000..676a790 --- /dev/null +++ b/pkg/errors/for_path_as_test.go @@ -0,0 +1,127 @@ +package errors_test + +import ( + "context" + "testing" + + "proto.zip/studio/validate/pkg/errors" + "proto.zip/studio/validate/pkg/rulecontext" +) + +// TestForPathAs_WithDefaultSerializer tests: +// - ForPathAs method works with default serializer +func TestForPathAs_WithDefaultSerializer(t *testing.T) { + ctx1 := rulecontext.WithPathString(context.Background(), "a") + ctx1 = rulecontext.WithPathString(ctx1, "b") + err1 := errors.Errorf(errors.CodeMin, ctx1, "short", "message1") + + ctx2 := rulecontext.WithPathString(context.Background(), "a") + ctx2 = rulecontext.WithPathString(ctx2, "c") + err2 := errors.Errorf(errors.CodeMin, ctx2, "short", "message2") + + collection := errors.Collection(err1, err2) + + serializer := errors.DefaultPathSerializer{} + filtered := collection.ForPathAs("/a/b", serializer) + + if len(filtered) != 1 { + t.Errorf("Expected 1 error, got: %d", len(filtered)) + } + + if filtered[0].Error() != "message1" { + t.Errorf("Expected error message 'message1', got: '%s'", filtered[0].Error()) + } +} + +// TestForPathAs_WithJSONPointerSerializer tests: +// - ForPathAs method works with JSON Pointer serializer +func TestForPathAs_WithJSONPointerSerializer(t *testing.T) { + ctx1 := rulecontext.WithPathString(context.Background(), "a") + ctx1 = rulecontext.WithPathString(ctx1, "b") + err1 := errors.Errorf(errors.CodeMin, ctx1, "short", "message1") + + ctx2 := rulecontext.WithPathString(context.Background(), "a") + ctx2 = rulecontext.WithPathString(ctx2, "c") + err2 := errors.Errorf(errors.CodeMin, ctx2, "short", "message2") + + collection := errors.Collection(err1, err2) + + serializer := errors.JSONPointerSerializer{} + filtered := collection.ForPathAs("/a/b", serializer) + + if len(filtered) != 1 { + t.Errorf("Expected 1 error, got: %d", len(filtered)) + } +} + +// TestForPathAs_WithJSONPathSerializer tests: +// - ForPathAs method works with JSONPath serializer +func TestForPathAs_WithJSONPathSerializer(t *testing.T) { + ctx1 := rulecontext.WithPathString(context.Background(), "a") + ctx1 = rulecontext.WithPathString(ctx1, "b") + err1 := errors.Errorf(errors.CodeMin, ctx1, "short", "message1") + + ctx2 := rulecontext.WithPathString(context.Background(), "a") + ctx2 = rulecontext.WithPathString(ctx2, "c") + err2 := errors.Errorf(errors.CodeMin, ctx2, "short", "message2") + + collection := errors.Collection(err1, err2) + + serializer := errors.JSONPathSerializer{} + filtered := collection.ForPathAs("$.a.b", serializer) + + if len(filtered) != 1 { + t.Errorf("Expected 1 error, got: %d", len(filtered)) + } +} + +// TestForPathAs_WithDotNotationSerializer tests: +// - ForPathAs method works with dot notation serializer +func TestForPathAs_WithDotNotationSerializer(t *testing.T) { + ctx1 := rulecontext.WithPathString(context.Background(), "a") + ctx1 = rulecontext.WithPathString(ctx1, "b") + err1 := errors.Errorf(errors.CodeMin, ctx1, "short", "message1") + + ctx2 := rulecontext.WithPathString(context.Background(), "a") + ctx2 = rulecontext.WithPathString(ctx2, "c") + err2 := errors.Errorf(errors.CodeMin, ctx2, "short", "message2") + + collection := errors.Collection(err1, err2) + + serializer := errors.DotNotationSerializer{} + filtered := collection.ForPathAs("a.b", serializer) + + if len(filtered) != 1 { + t.Errorf("Expected 1 error, got: %d", len(filtered)) + } +} + +// TestForPathAs_NoMatches tests: +// - ForPathAs returns empty collection when no matches +func TestForPathAs_NoMatches(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + collection := errors.Collection(err) + + serializer := errors.DefaultPathSerializer{} + filtered := collection.ForPathAs("/nonexistent", serializer) + + if len(filtered) != 0 { + t.Errorf("Expected empty collection, got: %d errors", len(filtered)) + } +} + +// TestForPathAs_EmptyCollection tests: +// - ForPathAs handles empty collection +func TestForPathAs_EmptyCollection(t *testing.T) { + collection := errors.Collection() + + serializer := errors.DefaultPathSerializer{} + filtered := collection.ForPathAs("/a/b", serializer) + + if len(filtered) != 0 { + t.Errorf("Expected nil or empty collection, got: %d errors", len(filtered)) + } +} diff --git a/pkg/errors/path_as_test.go b/pkg/errors/path_as_test.go new file mode 100644 index 0000000..70ff39d --- /dev/null +++ b/pkg/errors/path_as_test.go @@ -0,0 +1,85 @@ +package errors_test + +import ( + "context" + "testing" + + "proto.zip/studio/validate/pkg/errors" + "proto.zip/studio/validate/pkg/rulecontext" +) + +// TestPathAs_WithDefaultSerializer tests: +// - PathAs method works with default serializer +func TestPathAs_WithDefaultSerializer(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.DefaultPathSerializer{} + path := err.PathAs(serializer) + + if path == "" { + t.Error("Expected non-empty path") + } +} + +// TestPathAs_WithJSONPointerSerializer tests: +// - PathAs method works with JSON Pointer serializer +func TestPathAs_WithJSONPointerSerializer(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.JSONPointerSerializer{} + path := err.PathAs(serializer) + + expected := "/a/b" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestPathAs_WithJSONPathSerializer tests: +// - PathAs method works with JSONPath serializer +func TestPathAs_WithJSONPathSerializer(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.JSONPathSerializer{} + path := err.PathAs(serializer) + + expected := "$.a.b" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestPathAs_WithDotNotationSerializer tests: +// - PathAs method works with dot notation serializer +func TestPathAs_WithDotNotationSerializer(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.DotNotationSerializer{} + path := err.PathAs(serializer) + + expected := "a.b" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestPathAs_EmptyPath tests: +// - PathAs returns empty string for errors without path +func TestPathAs_EmptyPath(t *testing.T) { + err := errors.Errorf(errors.CodeMin, context.Background(), "short", "message") + + serializer := errors.DefaultPathSerializer{} + path := err.PathAs(serializer) + + if path != "" { + t.Errorf("Expected empty path, got: '%s'", path) + } +} diff --git a/pkg/errors/path_serializer.go b/pkg/errors/path_serializer.go new file mode 100644 index 0000000..3f4ab94 --- /dev/null +++ b/pkg/errors/path_serializer.go @@ -0,0 +1,30 @@ +package errors + +import ( + "proto.zip/studio/validate/pkg/rulecontext" +) + +// PathSerializer serializes an array of path segments into a string representation. +type PathSerializer interface { + Serialize(segments []rulecontext.PathSegment) string +} + +// extractPathSegments extracts all segments from a PathSegment into an array, +// ordered from root to leaf (top to bottom). +// segment must not be nil (caller is responsible for checking). +func extractPathSegments(segment rulecontext.PathSegment) []rulecontext.PathSegment { + // First, collect all segments by traversing up to the root + var segments []rulecontext.PathSegment + current := segment + for current != nil { + segments = append(segments, current) + current = current.Parent() + } + + // Reverse to get root-to-leaf order + for i, j := 0, len(segments)-1; i < j; i, j = i+1, j-1 { + segments[i], segments[j] = segments[j], segments[i] + } + + return segments +} diff --git a/pkg/errors/path_serializer_default.go b/pkg/errors/path_serializer_default.go new file mode 100644 index 0000000..ee20adc --- /dev/null +++ b/pkg/errors/path_serializer_default.go @@ -0,0 +1,43 @@ +package errors + +import ( + "proto.zip/studio/validate/pkg/rulecontext" +) + +// DefaultPathSerializer implements the default path serialization format. +// This matches the current behavior: "/segment1/segment2" for string segments +// and "/segment1/0" for index segments. +// If the first segment is an index with no parent, it returns just the index without a leading "/". +type DefaultPathSerializer struct{} + +// Serialize serializes path segments using the default format. +// String segments are separated by "/" with a leading "/". +// Index segments are represented as their numeric value. +// If the path starts with an index segment, no leading "/" is added. +func (s DefaultPathSerializer) Serialize(segments []rulecontext.PathSegment) string { + if len(segments) == 0 { + return "" + } + + // If first segment is an index, don't add leading "/" (matches current behavior) + if len(segments) > 0 { + if _, ok := segments[0].(*rulecontext.PathSegmentIndex); ok { + var result string + for i, seg := range segments { + if i > 0 { + result += "/" + } + result += seg.String() + } + return result + } + } + + // Otherwise, use leading "/" for string segments + var result string + for _, seg := range segments { + result += "/" + seg.String() + } + + return result +} diff --git a/pkg/errors/path_serializer_default_test.go b/pkg/errors/path_serializer_default_test.go new file mode 100644 index 0000000..974791c --- /dev/null +++ b/pkg/errors/path_serializer_default_test.go @@ -0,0 +1,117 @@ +package errors_test + +import ( + "context" + "testing" + + "proto.zip/studio/validate/pkg/errors" + "proto.zip/studio/validate/pkg/rulecontext" +) + +// TestDefaultPathSerializer_StringSegments tests: +// - Default serializer with string segments +func TestDefaultPathSerializer_StringSegments(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.DefaultPathSerializer{} + path := err.PathAs(serializer) + + expected := "/a/b" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestDefaultPathSerializer_IndexSegments tests: +// - Default serializer with index segments +func TestDefaultPathSerializer_IndexSegments(t *testing.T) { + ctx := rulecontext.WithPathIndex(context.Background(), 0) + ctx = rulecontext.WithPathIndex(ctx, 1) + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.DefaultPathSerializer{} + path := err.PathAs(serializer) + + expected := "0/1" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestDefaultPathSerializer_MixedSegments tests: +// - Default serializer with mixed string and index segments +func TestDefaultPathSerializer_MixedSegments(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + ctx = rulecontext.WithPathIndex(ctx, 0) + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.DefaultPathSerializer{} + path := err.PathAs(serializer) + + expected := "/a/b/0" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestDefaultPathSerializer_SingleIndex tests: +// - Default serializer with single index segment (no leading slash) +func TestDefaultPathSerializer_SingleIndex(t *testing.T) { + ctx := rulecontext.WithPathIndex(context.Background(), 5) + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.DefaultPathSerializer{} + path := err.PathAs(serializer) + + expected := "5" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestDefaultPathSerializer_SingleString tests: +// - Default serializer with single string segment +func TestDefaultPathSerializer_SingleString(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "field") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.DefaultPathSerializer{} + path := err.PathAs(serializer) + + expected := "/field" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestDefaultPathSerializer_EmptyPath tests: +// - Default serializer with empty path +func TestDefaultPathSerializer_EmptyPath(t *testing.T) { + err := errors.Errorf(errors.CodeMin, context.Background(), "short", "message") + + serializer := errors.DefaultPathSerializer{} + path := err.PathAs(serializer) + + if path != "" { + t.Errorf("Expected empty path, got: '%s'", path) + } +} + +// TestDefaultPathSerializer_MatchesPath tests: +// - Default serializer should match the default Path() behavior +func TestDefaultPathSerializer_MatchesPath(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.DefaultPathSerializer{} + pathAs := err.PathAs(serializer) + path := err.Path() + + if pathAs != path { + t.Errorf("Expected PathAs to match Path, got PathAs: '%s', Path: '%s'", pathAs, path) + } +} diff --git a/pkg/errors/path_serializer_dot.go b/pkg/errors/path_serializer_dot.go new file mode 100644 index 0000000..5e18f1d --- /dev/null +++ b/pkg/errors/path_serializer_dot.go @@ -0,0 +1,54 @@ +package errors + +import ( + "fmt" + "strings" + + "proto.zip/studio/validate/pkg/rulecontext" +) + +// DotNotationSerializer implements dot notation serialization format. +// Dot notation uses "field1.field2[0]" format without a prefix. +type DotNotationSerializer struct{} + +// Serialize serializes path segments using dot notation format. +// The format is: segment1.segment2[0] +// String segments are separated by "." and index segments use bracket notation. +func (s DotNotationSerializer) Serialize(segments []rulecontext.PathSegment) string { + if len(segments) == 0 { + return "" + } + + var result strings.Builder + + for i, seg := range segments { + switch v := seg.(type) { + case *rulecontext.PathSegmentIndex: + result.WriteString(fmt.Sprintf("[%d]", v.Index())) + case *rulecontext.PathSegmentString: + // Escape special characters in dot notation + escaped := escapeDotNotation(v.Segment()) + // Add "." if not the first segment (whether previous was index or string) + // But if escaped segment starts with '[', don't add dot (it's bracket notation) + if i > 0 && !strings.HasPrefix(escaped, "[") { + result.WriteString(".") + } + result.WriteString(escaped) + } + } + + return result.String() +} + +// escapeDotNotation escapes special characters in dot notation format. +// For segments containing dots or brackets, we use bracket notation with quotes. +func escapeDotNotation(s string) string { + // If the segment contains dots, brackets, or other special characters, + // we should use bracket notation with quotes: ['field.name'] + if strings.ContainsAny(s, ".[]") { + // Escape single quotes in the value + escaped := strings.ReplaceAll(s, "'", "\\'") + return fmt.Sprintf("['%s']", escaped) + } + return s +} diff --git a/pkg/errors/path_serializer_dot_test.go b/pkg/errors/path_serializer_dot_test.go new file mode 100644 index 0000000..d343fd1 --- /dev/null +++ b/pkg/errors/path_serializer_dot_test.go @@ -0,0 +1,119 @@ +package errors_test + +import ( + "context" + "testing" + + "proto.zip/studio/validate/pkg/errors" + "proto.zip/studio/validate/pkg/rulecontext" +) + +// TestDotNotationSerializer_StringSegments tests: +// - Dot notation serializer with string segments +func TestDotNotationSerializer_StringSegments(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.DotNotationSerializer{} + path := err.PathAs(serializer) + + expected := "a.b" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestDotNotationSerializer_IndexSegments tests: +// - Dot notation serializer with index segments +func TestDotNotationSerializer_IndexSegments(t *testing.T) { + ctx := rulecontext.WithPathIndex(context.Background(), 0) + ctx = rulecontext.WithPathIndex(ctx, 1) + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.DotNotationSerializer{} + path := err.PathAs(serializer) + + expected := "[0][1]" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestDotNotationSerializer_MixedSegments tests: +// - Dot notation serializer with mixed string and index segments +func TestDotNotationSerializer_MixedSegments(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + ctx = rulecontext.WithPathIndex(ctx, 0) + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.DotNotationSerializer{} + path := err.PathAs(serializer) + + expected := "a.b[0]" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestDotNotationSerializer_ComplexPath tests: +// - Dot notation serializer with complex path +func TestDotNotationSerializer_ComplexPath(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "users") + ctx = rulecontext.WithPathIndex(ctx, 0) + ctx = rulecontext.WithPathString(ctx, "name") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.DotNotationSerializer{} + path := err.PathAs(serializer) + + expected := "users[0].name" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestDotNotationSerializer_SpecialCharacters tests: +// - Dot notation serializer handles special characters +func TestDotNotationSerializer_SpecialCharacters(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "field.name") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.DotNotationSerializer{} + path := err.PathAs(serializer) + + // Should use bracket notation for fields with dots + expected := "['field.name']" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestDotNotationSerializer_EmptyPath tests: +// - Dot notation serializer with empty path +func TestDotNotationSerializer_EmptyPath(t *testing.T) { + err := errors.Errorf(errors.CodeMin, context.Background(), "short", "message") + + serializer := errors.DotNotationSerializer{} + path := err.PathAs(serializer) + + if path != "" { + t.Errorf("Expected empty path, got: '%s'", path) + } +} + +// TestDotNotationSerializer_SingleSegment tests: +// - Dot notation serializer with single segment +func TestDotNotationSerializer_SingleSegment(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "field") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.DotNotationSerializer{} + path := err.PathAs(serializer) + + expected := "field" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} diff --git a/pkg/errors/path_serializer_internal_test.go b/pkg/errors/path_serializer_internal_test.go new file mode 100644 index 0000000..764812f --- /dev/null +++ b/pkg/errors/path_serializer_internal_test.go @@ -0,0 +1,72 @@ +package errors + +import ( + "context" + "testing" + + "proto.zip/studio/validate/pkg/rulecontext" +) + +// TestExtractPathSegments_SingleSegment tests: +// - extractPathSegments with a single segment (tests reversal loop with one element) +func TestExtractPathSegments_SingleSegment(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "field") + segment := rulecontext.Path(ctx) + + segments := extractPathSegments(segment) + + if len(segments) != 1 { + t.Errorf("Expected 1 segment, got: %d", len(segments)) + } + if segments[0].String() != "field" { + t.Errorf("Expected segment to be 'field', got: '%s'", segments[0].String()) + } +} + +// TestExtractPathSegments_MultipleSegments tests: +// - extractPathSegments with multiple segments (tests reversal) +func TestExtractPathSegments_MultipleSegments(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + ctx = rulecontext.WithPathString(ctx, "c") + segment := rulecontext.Path(ctx) + + segments := extractPathSegments(segment) + + if len(segments) != 3 { + t.Errorf("Expected 3 segments, got: %d", len(segments)) + } + if segments[0].String() != "a" { + t.Errorf("Expected first segment to be 'a', got: '%s'", segments[0].String()) + } + if segments[1].String() != "b" { + t.Errorf("Expected second segment to be 'b', got: '%s'", segments[1].String()) + } + if segments[2].String() != "c" { + t.Errorf("Expected third segment to be 'c', got: '%s'", segments[2].String()) + } +} + +// TestExtractPathSegments_MixedSegments tests: +// - extractPathSegments with mixed string and index segments +func TestExtractPathSegments_MixedSegments(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathIndex(ctx, 0) + ctx = rulecontext.WithPathString(ctx, "b") + segment := rulecontext.Path(ctx) + + segments := extractPathSegments(segment) + + if len(segments) != 3 { + t.Errorf("Expected 3 segments, got: %d", len(segments)) + } + if segments[0].String() != "a" { + t.Errorf("Expected first segment to be 'a', got: '%s'", segments[0].String()) + } + if segments[1].String() != "0" { + t.Errorf("Expected second segment to be '0', got: '%s'", segments[1].String()) + } + if segments[2].String() != "b" { + t.Errorf("Expected third segment to be 'b', got: '%s'", segments[2].String()) + } +} diff --git a/pkg/errors/path_serializer_json_pointer.go b/pkg/errors/path_serializer_json_pointer.go new file mode 100644 index 0000000..36ffbb4 --- /dev/null +++ b/pkg/errors/path_serializer_json_pointer.go @@ -0,0 +1,45 @@ +package errors + +import ( + "strings" + + "proto.zip/studio/validate/pkg/rulecontext" +) + +// JSONPointerSerializer implements JSON Pointer serialization as defined in RFC 6901. +// JSON Pointer uses "/" to separate segments and escapes "/" as "~1" and "~" as "~0". +type JSONPointerSerializer struct{} + +// Serialize serializes path segments using JSON Pointer format (RFC 6901). +// The format is: /segment1/segment2/0 +// Special characters are escaped: "/" becomes "~1" and "~" becomes "~0". +func (s JSONPointerSerializer) Serialize(segments []rulecontext.PathSegment) string { + if len(segments) == 0 { + return "" + } + + var parts []string + for _, seg := range segments { + var part string + switch v := seg.(type) { + case *rulecontext.PathSegmentIndex: + part = v.String() + case *rulecontext.PathSegmentString: + // Escape JSON Pointer special characters + part = escapeJSONPointer(v.Segment()) + } + parts = append(parts, part) + } + + return "/" + strings.Join(parts, "/") +} + +// escapeJSONPointer escapes special characters in JSON Pointer format. +// According to RFC 6901: +// - "~" must be encoded as "~0" +// - "/" must be encoded as "~1" +func escapeJSONPointer(s string) string { + s = strings.ReplaceAll(s, "~", "~0") + s = strings.ReplaceAll(s, "/", "~1") + return s +} diff --git a/pkg/errors/path_serializer_json_pointer_rfc6901_test.go b/pkg/errors/path_serializer_json_pointer_rfc6901_test.go new file mode 100644 index 0000000..9d92836 --- /dev/null +++ b/pkg/errors/path_serializer_json_pointer_rfc6901_test.go @@ -0,0 +1,87 @@ +package errors_test + +import ( + "context" + "testing" + + "proto.zip/studio/validate/pkg/errors" + "proto.zip/studio/validate/pkg/rulecontext" +) + +// TestJSONPointerSerializer_RFC6901Compliance tests: +// - JSON Pointer serializer correctly implements RFC 6901 escaping rules +// RFC 6901 specifies: +// - "~" must be encoded as "~0" +// - "/" must be encoded as "~1" +// - These are the only two escape sequences +func TestJSONPointerSerializer_RFC6901Compliance(t *testing.T) { + tests := []struct { + name string + segment string + expected string + desc string + }{ + { + name: "RFC 6901 example: m~n", + segment: "m~n", + expected: "/m~0n", + desc: "From RFC 6901: key 'm~n' should serialize to '/m~0n'", + }, + { + name: "RFC 6901 example: a/b", + segment: "a/b", + expected: "/a~1b", + desc: "From RFC 6901: key 'a/b' should serialize to '/a~1b'", + }, + { + name: "literal ~0 in input", + segment: "a~0b", + expected: "/a~00b", + desc: "Literal '~0' should become '~00' (escaped tilde + 0)", + }, + { + name: "literal ~1 in input", + segment: "a~1b", + expected: "/a~01b", + desc: "Literal '~1' should become '~01' (escaped tilde + 1)", + }, + { + name: "multiple tildes", + segment: "a~~b", + expected: "/a~0~0b", + desc: "Multiple tildes should each be escaped", + }, + { + name: "multiple slashes", + segment: "a//b", + expected: "/a~1~1b", + desc: "Multiple slashes should each be escaped", + }, + { + name: "tilde then slash", + segment: "a~/b", + expected: "/a~0~1b", + desc: "Tilde then slash: '~' becomes '~0', '/' becomes '~1'", + }, + { + name: "slash then tilde", + segment: "a/~b", + expected: "/a~1~0b", + desc: "Slash then tilde: '/' becomes '~1', '~' becomes '~0'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), tt.segment) + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.JSONPointerSerializer{} + path := err.PathAs(serializer) + + if path != tt.expected { + t.Errorf("%s\nExpected: '%s'\nGot: '%s'", tt.desc, tt.expected, path) + } + }) + } +} diff --git a/pkg/errors/path_serializer_json_pointer_test.go b/pkg/errors/path_serializer_json_pointer_test.go new file mode 100644 index 0000000..0c534ce --- /dev/null +++ b/pkg/errors/path_serializer_json_pointer_test.go @@ -0,0 +1,189 @@ +package errors_test + +import ( + "context" + "testing" + + "proto.zip/studio/validate/pkg/errors" + "proto.zip/studio/validate/pkg/rulecontext" +) + +// TestJSONPointerSerializer_StringSegments tests: +// - JSON Pointer serializer with string segments +func TestJSONPointerSerializer_StringSegments(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.JSONPointerSerializer{} + path := err.PathAs(serializer) + + expected := "/a/b" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestJSONPointerSerializer_IndexSegments tests: +// - JSON Pointer serializer with index segments +func TestJSONPointerSerializer_IndexSegments(t *testing.T) { + ctx := rulecontext.WithPathIndex(context.Background(), 0) + ctx = rulecontext.WithPathIndex(ctx, 1) + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.JSONPointerSerializer{} + path := err.PathAs(serializer) + + expected := "/0/1" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestJSONPointerSerializer_MixedSegments tests: +// - JSON Pointer serializer with mixed string and index segments +func TestJSONPointerSerializer_MixedSegments(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + ctx = rulecontext.WithPathIndex(ctx, 0) + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.JSONPointerSerializer{} + path := err.PathAs(serializer) + + expected := "/a/b/0" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestJSONPointerSerializer_Escaping tests: +// - JSON Pointer serializer escapes special characters according to RFC 6901 +func TestJSONPointerSerializer_Escaping(t *testing.T) { + tests := []struct { + name string + segments []string + expected string + }{ + { + name: "segment with slash", + segments: []string{"a/b"}, + expected: "/a~1b", + }, + { + name: "segment with tilde", + segments: []string{"c~d"}, + expected: "/c~0d", + }, + { + name: "segment with both tilde and slash", + segments: []string{"a~/b"}, + expected: "/a~0~1b", + }, + { + name: "segment starting with tilde", + segments: []string{"~something"}, + expected: "/~0something", + }, + { + name: "segment starting with slash", + segments: []string{"/leading"}, + expected: "/~1leading", + }, + { + name: "segment ending with tilde", + segments: []string{"ending~"}, + expected: "/ending~0", + }, + { + name: "segment ending with slash", + segments: []string{"ending/"}, + expected: "/ending~1", + }, + { + name: "segment with tilde followed by 0 (literal ~0)", + segments: []string{"a~0b"}, + expected: "/a~00b", + }, + { + name: "segment with tilde followed by 1 (literal ~1)", + segments: []string{"a~1b"}, + expected: "/a~01b", + }, + { + name: "multiple segments with special chars", + segments: []string{"a~b", "c/d", "e~f"}, + expected: "/a~0b/c~1d/e~0f", + }, + { + name: "empty segment", + segments: []string{""}, + expected: "/", + }, + { + name: "segment with only tilde", + segments: []string{"~"}, + expected: "/~0", + }, + { + name: "segment with only slash", + segments: []string{"/"}, + expected: "/~1", + }, + { + name: "segment with multiple tildes", + segments: []string{"a~~b"}, + expected: "/a~0~0b", + }, + { + name: "segment with multiple slashes", + segments: []string{"a//b"}, + expected: "/a~1~1b", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + for _, seg := range tt.segments { + ctx = rulecontext.WithPathString(ctx, seg) + } + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.JSONPointerSerializer{} + path := err.PathAs(serializer) + + if path != tt.expected { + t.Errorf("Expected path to be '%s', got: '%s'", tt.expected, path) + } + }) + } +} + +// TestJSONPointerSerializer_EmptyPath tests: +// - JSON Pointer serializer with empty path +func TestJSONPointerSerializer_EmptyPath(t *testing.T) { + err := errors.Errorf(errors.CodeMin, context.Background(), "short", "message") + + serializer := errors.JSONPointerSerializer{} + path := err.PathAs(serializer) + + if path != "" { + t.Errorf("Expected empty path, got: '%s'", path) + } +} + +// TestJSONPointerSerializer_SingleSegment tests: +// - JSON Pointer serializer with single segment +func TestJSONPointerSerializer_SingleSegment(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "field") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.JSONPointerSerializer{} + path := err.PathAs(serializer) + + expected := "/field" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} diff --git a/pkg/errors/path_serializer_jsonpath.go b/pkg/errors/path_serializer_jsonpath.go new file mode 100644 index 0000000..3153cea --- /dev/null +++ b/pkg/errors/path_serializer_jsonpath.go @@ -0,0 +1,62 @@ +package errors + +import ( + "fmt" + "strings" + + "proto.zip/studio/validate/pkg/rulecontext" +) + +// JSONPathSerializer implements JSONPath serialization format. +// JSONPath uses "$.field1.field2[0]" format with "$" prefix and brackets for indices. +type JSONPathSerializer struct{} + +// Serialize serializes path segments using JSONPath format. +// The format is: $.segment1.segment2[0] +// String segments are separated by "." and index segments use bracket notation. +func (s JSONPathSerializer) Serialize(segments []rulecontext.PathSegment) string { + if len(segments) == 0 { + return "$" + } + + var result strings.Builder + result.WriteString("$") + firstSegment := true + + for _, seg := range segments { + switch v := seg.(type) { + case *rulecontext.PathSegmentIndex: + result.WriteString(fmt.Sprintf("[%d]", v.Index())) + firstSegment = false + case *rulecontext.PathSegmentString: + // Escape special characters in JSONPath + escaped := escapeJSONPath(v.Segment()) + // Add "." if not the first segment (whether previous was index or string) + // But if escaped segment starts with '[', don't add dot (it's bracket notation) + if !firstSegment && !strings.HasPrefix(escaped, "[") { + result.WriteString(".") + } else if firstSegment && !strings.HasPrefix(escaped, "[") { + // First string segment after $ needs a dot (unless it's bracket notation) + result.WriteString(".") + } + result.WriteString(escaped) + firstSegment = false + } + } + + return result.String() +} + +// escapeJSONPath escapes special characters in JSONPath format. +// In JSONPath, dots and brackets need to be escaped or quoted. +// For simplicity, we'll use bracket notation for segments containing special characters. +func escapeJSONPath(s string) string { + // If the segment contains dots, brackets, or other special characters, + // we should use bracket notation with quotes: ['field.name'] + if strings.ContainsAny(s, ".[]") { + // Escape single quotes in the value + escaped := strings.ReplaceAll(s, "'", "\\'") + return fmt.Sprintf("['%s']", escaped) + } + return s +} diff --git a/pkg/errors/path_serializer_jsonpath_test.go b/pkg/errors/path_serializer_jsonpath_test.go new file mode 100644 index 0000000..78cd764 --- /dev/null +++ b/pkg/errors/path_serializer_jsonpath_test.go @@ -0,0 +1,120 @@ +package errors_test + +import ( + "context" + "testing" + + "proto.zip/studio/validate/pkg/errors" + "proto.zip/studio/validate/pkg/rulecontext" +) + +// TestJSONPathSerializer_StringSegments tests: +// - JSONPath serializer with string segments +func TestJSONPathSerializer_StringSegments(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.JSONPathSerializer{} + path := err.PathAs(serializer) + + expected := "$.a.b" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestJSONPathSerializer_IndexSegments tests: +// - JSONPath serializer with index segments +func TestJSONPathSerializer_IndexSegments(t *testing.T) { + ctx := rulecontext.WithPathIndex(context.Background(), 0) + ctx = rulecontext.WithPathIndex(ctx, 1) + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.JSONPathSerializer{} + path := err.PathAs(serializer) + + expected := "$[0][1]" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestJSONPathSerializer_MixedSegments tests: +// - JSONPath serializer with mixed string and index segments +func TestJSONPathSerializer_MixedSegments(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "a") + ctx = rulecontext.WithPathString(ctx, "b") + ctx = rulecontext.WithPathIndex(ctx, 0) + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.JSONPathSerializer{} + path := err.PathAs(serializer) + + expected := "$.a.b[0]" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestJSONPathSerializer_ComplexPath tests: +// - JSONPath serializer with complex path +func TestJSONPathSerializer_ComplexPath(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "users") + ctx = rulecontext.WithPathIndex(ctx, 0) + ctx = rulecontext.WithPathString(ctx, "name") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.JSONPathSerializer{} + path := err.PathAs(serializer) + + expected := "$.users[0].name" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestJSONPathSerializer_SpecialCharacters tests: +// - JSONPath serializer handles special characters +func TestJSONPathSerializer_SpecialCharacters(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "field.name") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.JSONPathSerializer{} + path := err.PathAs(serializer) + + // Should use bracket notation for fields with dots + expected := "$['field.name']" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestJSONPathSerializer_EmptyPath tests: +// - JSONPath serializer with empty path +func TestJSONPathSerializer_EmptyPath(t *testing.T) { + err := errors.Errorf(errors.CodeMin, context.Background(), "short", "message") + + serializer := errors.JSONPathSerializer{} + path := err.PathAs(serializer) + + expected := "$" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} + +// TestJSONPathSerializer_SingleSegment tests: +// - JSONPath serializer with single segment +func TestJSONPathSerializer_SingleSegment(t *testing.T) { + ctx := rulecontext.WithPathString(context.Background(), "field") + err := errors.Errorf(errors.CodeMin, ctx, "short", "message") + + serializer := errors.JSONPathSerializer{} + path := err.PathAs(serializer) + + expected := "$.field" + if path != expected { + t.Errorf("Expected path to be '%s', got: '%s'", expected, path) + } +} diff --git a/pkg/errors/path_serializer_test.go b/pkg/errors/path_serializer_test.go new file mode 100644 index 0000000..049bf82 --- /dev/null +++ b/pkg/errors/path_serializer_test.go @@ -0,0 +1,11 @@ +package errors_test + +import ( + "testing" +) + +// TestExtractPathSegments tests the extraction of path segments. +func TestExtractPathSegments(t *testing.T) { + // This is tested indirectly through the serializer tests + // since extractPathSegments is a private function +} diff --git a/pkg/errors/validation_error.go b/pkg/errors/validation_error.go index 052ac67..b27955b 100644 --- a/pkg/errors/validation_error.go +++ b/pkg/errors/validation_error.go @@ -9,14 +9,15 @@ import ( // ValidationError stores information necessary to identify where the validation error // is, as well as implementing the Error interface to work with standard errors. type ValidationError interface { - Code() ErrorCode // Code returns the error code. - Path() string // Path returns the full path to the error as a string. - Error() string // Error returns the detailed error message (satisfies Go error interface). - ShortError() string // ShortError returns a brief constant error description. - DocsURI() string // DocsURI returns an optional documentation URL for the error. - TraceURI() string // TraceURI returns an optional trace/debug URL for the error. - Meta() map[string]any // Meta returns arbitrary metadata associated with the error. - Params() []any // Params returns the format arguments used to create the error message. + Code() ErrorCode // Code returns the error code. + Path() string // Path returns the full path to the error as a string. + PathAs(serializer PathSerializer) string // PathAs returns the full path using the provided serializer. + Error() string // Error returns the detailed error message (satisfies Go error interface). + ShortError() string // ShortError returns a brief constant error description. + DocsURI() string // DocsURI returns an optional documentation URL for the error. + TraceURI() string // TraceURI returns an optional trace/debug URL for the error. + Meta() map[string]any // Meta returns arbitrary metadata associated with the error. + Params() []any // Params returns the format arguments used to create the error message. // Type classification methods Internal() bool // Internal returns true if the error is an internal error. @@ -111,11 +112,18 @@ func (err *validationError) Code() ErrorCode { } // Path returns the full path to the error as a string. +// Path is a wrapper around PathAs using the default serializer. func (err *validationError) Path() string { - if err.pathSegment == nil { - return "" + return err.PathAs(DefaultPathSerializer{}) +} + +// PathAs returns the full path to the error as a string using the provided serializer. +func (err *validationError) PathAs(serializer PathSerializer) string { + var segments []rulecontext.PathSegment + if err.pathSegment != nil { + segments = extractPathSegments(err.pathSegment) } - return err.pathSegment.FullString() + return serializer.Serialize(segments) } // DocsURI returns an optional documentation URL for the error. diff --git a/pkg/rulecontext/context_test.go b/pkg/rulecontext/context_test.go index 500e79e..f05ac2c 100644 --- a/pkg/rulecontext/context_test.go +++ b/pkg/rulecontext/context_test.go @@ -6,6 +6,7 @@ import ( "golang.org/x/text/language" "golang.org/x/text/message" + "proto.zip/studio/validate/pkg/errors" "proto.zip/studio/validate/pkg/rulecontext" ) @@ -152,7 +153,13 @@ func TestPathRuleSet(t *testing.T) { t.Error("Expected path segment to not be nil") } else if p.String() != segmentB { t.Errorf("Expected path segment to be `%s` got `%s`", segmentB, p.String()) - } else if p.FullString() != expectedFullPath { - t.Errorf("Expected full path to be `%s` got `%s`", expectedFullPath, p.FullString()) + } else { + // Use the default serializer to get the full path + serializer := errors.DefaultPathSerializer{} + segments := extractPathSegmentsForTest(p) + fullpath := serializer.Serialize(segments) + if fullpath != expectedFullPath { + t.Errorf("Expected full path to be `%s` got `%s`", expectedFullPath, fullpath) + } } } diff --git a/pkg/rulecontext/path.go b/pkg/rulecontext/path.go index f51e150..8e1aa46 100644 --- a/pkg/rulecontext/path.go +++ b/pkg/rulecontext/path.go @@ -5,65 +5,68 @@ import ( "fmt" ) -// PathSegment represents a segment in a validation path. -// PathSegment can be either a string segment or an index segment. -type PathSegment interface { - Parent() PathSegment +// pathSegment represents a segment in a validation path. +// pathSegment can be either a string segment or an index segment. +// This interface is unexported to prevent external implementations. +type pathSegment interface { + Parent() pathSegment String() string - FullString() string } -type pathSegmentString struct { - parent PathSegment +// PathSegment represents a segment in a validation path. +// PathSegment is a type alias for the unexported pathSegment interface. +// While external code can reference PathSegment, they cannot implement it +// because the underlying pathSegment interface is unexported and the concrete +// types (PathSegmentString and PathSegmentIndex) are the only implementations. +type PathSegment = pathSegment + +// PathSegmentString represents a string segment in a validation path. +type PathSegmentString struct { + parent pathSegment segment string } -type pathSegmentIndex struct { - parent PathSegment +// PathSegmentIndex represents an index segment in a validation path. +type PathSegmentIndex struct { + parent pathSegment segment int } // Parent returns the previous path segment. -func (s *pathSegmentString) Parent() PathSegment { +func (s *PathSegmentString) Parent() pathSegment { return s.parent } // String returns the segment as a string. -func (s *pathSegmentString) String() string { +func (s *PathSegmentString) String() string { return s.segment } -// FullString returns the full path until there are no more parent segments. -func (s *pathSegmentString) FullString() string { - if s.parent != nil { - return s.parent.FullString() + "/" + s.String() - } - return "/" + s.String() +// Segment returns the string value of this segment. +func (s *PathSegmentString) Segment() string { + return s.segment } // Parent returns the previous path segment. -func (s *pathSegmentIndex) Parent() PathSegment { +func (s *PathSegmentIndex) Parent() pathSegment { return s.parent } // String returns the index as a string using brackets. // // Example: [0] or [3] -func (s *pathSegmentIndex) String() string { +func (s *PathSegmentIndex) String() string { return fmt.Sprintf("%d", s.segment) } -// FullString returns the full path until there are no more parent segments. -func (s *pathSegmentIndex) FullString() string { - if s.parent != nil { - return s.parent.FullString() + "/" + s.String() - } - return s.String() +// Index returns the numeric index value of this segment. +func (s *PathSegmentIndex) Index() int { + return s.segment } // WithPathString returns a new context with the path segment added. func WithPathString(parent context.Context, value string) context.Context { - newPath := &pathSegmentString{ + newPath := &PathSegmentString{ segment: value, } @@ -76,7 +79,7 @@ func WithPathString(parent context.Context, value string) context.Context { // WithPathIndex returns a new context with the path segment index added. func WithPathIndex(parent context.Context, value int) context.Context { - newPath := &pathSegmentIndex{ + newPath := &PathSegmentIndex{ segment: value, } diff --git a/pkg/rulecontext/path_jsonpointer_test.go b/pkg/rulecontext/path_jsonpointer_test.go new file mode 100644 index 0000000..453d42e --- /dev/null +++ b/pkg/rulecontext/path_jsonpointer_test.go @@ -0,0 +1,87 @@ +package rulecontext_test + +import ( + "context" + "testing" + + "proto.zip/studio/validate/pkg/errors" + "proto.zip/studio/validate/pkg/rulecontext" +) + +// TestPathJSONPointerEscaping tests that path segments with special characters +// are properly escaped according to RFC 6901 when serialized as JSON Pointer. +// +// RFC 6901 requires: +// - `~` must be escaped as `~0` +// - `/` must be escaped as `~1` +func TestPathJSONPointerEscaping(t *testing.T) { + tests := []struct { + name string + segments []string + expected string + }{ + { + name: "segment with tilde", + segments: []string{"a~b"}, + expected: "/a~0b", + }, + { + name: "segment with slash", + segments: []string{"a/b"}, + expected: "/a~1b", + }, + { + name: "segment with both tilde and slash", + segments: []string{"a~/b"}, + expected: "/a~0~1b", + }, + { + name: "segment starting with tilde", + segments: []string{"~something"}, + expected: "/~0something", + }, + { + name: "segment starting with slash", + segments: []string{"/leading"}, + expected: "/~1leading", + }, + { + name: "multiple segments with special chars", + segments: []string{"a~b", "c/d", "e~f"}, + expected: "/a~0b/c~1d/e~0f", + }, + { + name: "normal segment", + segments: []string{"normal"}, + expected: "/normal", + }, + { + name: "empty segment", + segments: []string{""}, + expected: "/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + for _, seg := range tt.segments { + ctx = rulecontext.WithPathString(ctx, seg) + } + + path := rulecontext.Path(ctx) + if path == nil { + t.Fatal("Expected path to not be nil") + } + + // Use JSON Pointer serializer to get the properly escaped path + serializer := errors.JSONPointerSerializer{} + segments := extractPathSegmentsForTest(path) + actual := serializer.Serialize(segments) + + if actual != tt.expected { + t.Errorf("Expected JSON Pointer path '%s', got '%s'", tt.expected, actual) + } + }) + } +} diff --git a/pkg/rulecontext/path_test.go b/pkg/rulecontext/path_test.go index 66731df..4d9718f 100644 --- a/pkg/rulecontext/path_test.go +++ b/pkg/rulecontext/path_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "proto.zip/studio/validate/pkg/errors" "proto.zip/studio/validate/pkg/rulecontext" ) @@ -19,11 +20,39 @@ func fullPathHelper(t testing.TB, ctx context.Context, expected string) { t.Fatal("Expected path to not be nil") } - if fullpath := path.FullString(); fullpath != expected { + // Use the default serializer to get the full path + serializer := errors.DefaultPathSerializer{} + segments := extractPathSegmentsForTest(path) + fullpath := serializer.Serialize(segments) + if fullpath != expected { t.Errorf("Expected full path to be '%s', got: '%s'", expected, fullpath) } } +// extractPathSegmentsForTest extracts all segments from a PathSegment into an array, +// ordered from root to leaf (top to bottom). This is a test helper that mirrors +// the private function in the errors package. +func extractPathSegmentsForTest(segment rulecontext.PathSegment) []rulecontext.PathSegment { + if segment == nil { + return nil + } + + // First, collect all segments by traversing up to the root + var segments []rulecontext.PathSegment + current := segment + for current != nil { + segments = append(segments, current) + current = current.Parent() + } + + // Reverse to get root-to-leaf order + for i, j := 0, len(segments)-1; i < j; i, j = i+1, j-1 { + segments[i], segments[j] = segments[j], segments[i] + } + + return segments +} + // TestPathNil tests: // - Returns nil when context is nil // - Returns nil when no path is set in context @@ -116,7 +145,7 @@ func TestWithPathCombined(t *testing.T) { } // TestPathIndexFullStringNilParent tests: -// - pathSegmentIndex.FullString() with nil parent returns just the index +// - PathSegmentIndex with nil parent serializes to just the index (no leading slash) func TestPathIndexFullStringNilParent(t *testing.T) { // Create a context with only an index path (no parent string path) ctx := rulecontext.WithPathIndex(context.Background(), 5) diff --git a/pkg/testhelpers/witherrorconfig_test.go b/pkg/testhelpers/witherrorconfig_test.go index 8e25ff4..c891eb9 100644 --- a/pkg/testhelpers/witherrorconfig_test.go +++ b/pkg/testhelpers/witherrorconfig_test.go @@ -289,6 +289,7 @@ type brokenValidationError struct { func (e *brokenValidationError) Code() errors.ErrorCode { return e.code } func (e *brokenValidationError) Path() string { return "" } +func (e *brokenValidationError) PathAs(serializer errors.PathSerializer) string { return "" } func (e *brokenValidationError) ShortError() string { return e.shortErr } func (e *brokenValidationError) Error() string { return e.longErr } func (e *brokenValidationError) DocsURI() string { return "" }