diff --git a/.agents/skills/govy/SKILL.md b/.agents/skills/govy/SKILL.md new file mode 100644 index 00000000..d8b75b7f --- /dev/null +++ b/.agents/skills/govy/SKILL.md @@ -0,0 +1,172 @@ +--- +name: govy +description: > + Use this skill when writing, reviewing, or explaining validation code with + github.com/nobl9/govy. Trigger for govy validators, property rules, custom + rules, predefined rules, govytest assertions, validation plans, error + messages, message templates, path inference, or any task that mentions the + govy library. +--- + +# Govy + +Govy is a validation library for Go that uses a functional interface for building +strongly-typed validation rules, powered by generics and reflection free. +It puts heavy focus on end user errors readability, +providing means of crafting clear and information-rich error messages. +It also allows writing self-documenting validation rules through a +validation plan. + +The references in this skill are self-contained. +Load only the reference that matches the task. + +## Reference selection + +| Task | Load | +| :--- | :--- | +| Build a validator, name it, add conditions, validate slices, or compose validators | [core-validation.md](references/core-validation.md) | +| Work with property getters, paths, pointers, transforms, required values, optional values, hidden values, or property-level conditions | [properties-and-paths.md](references/properties-and-paths.md) | +| Create or modify rules, rule sets, error details, examples, error codes, custom messages, or message templates | [rules-and-messages.md](references/rules-and-messages.md) | +| Validate nested objects, slices, maps, each element, or compose validators over collections | [collections-and-composition.md](references/collections-and-composition.md) | +| Inspect, serialize, rename, or construct govy errors | [errors.md](references/errors.md) | +| Test govy validation behavior without the govytest package | [testing.md](references/testing.md) | +| Generate or validate a validation plan | [validation-plan.md](references/validation-plan.md) | +| Configure runtime or generated path inference | [path-inference.md](references/path-inference.md) | +| Choose from existing predefined rules in `pkg/rules` | [existing-rules.md](references/existing-rules.md) | +| See examples of predefined rules in use | [predefined-rules.md](references/predefined-rules.md) | +| Use `pkg/govytest` assertion helpers | [govytest.md](references/govytest.md) | + +## Usage guidance + +- Define rules and validators once and reuse them across validation calls. + They are relatively costly to construct and may lazily initialize internal + state during first use, so avoid rebuilding them every time validation runs. +- Keep validator declarations immutable-style: chained methods return copies, + so derive runtime variants from existing validators instead of mutating them + in place. +- Prefer explicit `WithName` or `WithPath` unless the user explicitly + mentions path inference or if the inference mechanism is already used. +- Use `WithName` for one path segment and `WithPath` for multi-segment paths. +- Use `ForPointer` for optional pointer fields so rules operate on the + pointed-to value and nil values are skipped unless `Required` is added. +- Use `Required` and `OmitEmpty` on property rules to short-circuit empty values + before running normal rules. +- Attach plan and error metadata to the receiver that owns it: + use `Rule.WithDescription`, `Rule.WithDetails`, `Rule.WithExamples`, + and `Rule.WithErrorCode` for rules; use `PropertyRules.WithExamples` + for property examples; use `govy.WhenDescription` for conditional rules. +- Use `govytest` helpers for tests that need to match structured validation + errors rather than brittle full error strings. + +## General example + +[//]: # (embed: internal/examples/readme_intro_example_test.go) + +```go +package examples + +import ( + "fmt" + "regexp" + "time" + + "github.com/nobl9/govy/pkg/govy" + "github.com/nobl9/govy/pkg/rules" +) + +func Example_basicUsage() { + type University struct { + Name string `json:"name"` + Address string `json:"address"` + } + type Student struct { + Index string `json:"index"` + } + type Teacher struct { + Name string `json:"name"` + Age time.Duration `json:"age"` + Students []Student `json:"students"` + MiddleName *string `json:"middleName,omitempty"` + University University `json:"university"` + } + + universityValidation := govy.New( + govy.For(func(u University) string { return u.Name }). + WithName("name"). + Required(), + govy.For(func(u University) string { return u.Address }). + WithName("address"). + Required(). + Rules(rules.StringMatchRegexp( + regexp.MustCompile(`[\w\s.]+, \d{2}-\d{3} \w+`), + ). + WithDetails("Polish address format must consist of the main address and zip code"). + WithExamples("5 M. Skłodowska-Curie Square, 60-965 Poznan")), + ) + studentValidator := govy.New( + govy.For(func(s Student) string { return s.Index }). + WithName("index"). + Rules(rules.StringLength(9, 9)), + ) + teacherValidator := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules( + rules.StringNotEmpty(), + rules.OneOf("Jake", "George")), + govy.ForPointer(func(t Teacher) *string { return t.MiddleName }). + WithName("middleName"). + Rules(rules.StringTitle()), + govy.ForSlice(func(t Teacher) []Student { return t.Students }). + WithName("students"). + Rules( + rules.SliceMaxLength[[]Student](2), + rules.SliceUnique(func(v Student) string { return v.Index })). + IncludeForEach(studentValidator), + govy.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(universityValidation), + ). + When(func(t Teacher) bool { return t.Age < 50 }) + + teacher := Teacher{ + Name: "John", + MiddleName: nil, // Validation for nil pointers by default is skipped. + Age: 48, + Students: []Student{ + {Index: "918230014"}, + {Index: "9182300123"}, + {Index: "918230014"}, + }, + University: University{ + Name: "", + Address: "10th University St.", + }, + } + + if err := teacherValidator.WithName("John").Validate(teacher); err != nil { + fmt.Println(err) + } + // When condition is not met, no validation errors. + johnFromTheFuture := teacher + johnFromTheFuture.Age = 51 + if err := teacherValidator.WithName("John From The Future").Validate(johnFromTheFuture); err != nil { + fmt.Println(err) + } + + // Output: + // Validation for John has failed for the following properties: + // - 'name' with value 'John': + // - must be one of: Jake, George + // - 'students' with value '[{"index":"918230014"},{"index":"9182300123"},{"index":"918230014"}]': + // - length must be less than or equal to 2 + // - elements are not unique, 1st and 3rd elements collide + // - 'students[1].index' with value '9182300123': + // - length must be between 9 and 9 + // - 'university.name': + // - property is required but was empty + // - 'university.address' with value '10th University St.': + // - string must match regular expression: '[\w\s.]+, \d{2}-\d{3} \w+' (e.g. '5 M. Skłodowska-Curie Square, 60-965 Poznan'); Polish address format must consist of the main address and zip code +} +``` diff --git a/.agents/skills/govy/references/collections-and-composition.md b/.agents/skills/govy/references/collections-and-composition.md new file mode 100644 index 00000000..137a936d --- /dev/null +++ b/.agents/skills/govy/references/collections-and-composition.md @@ -0,0 +1,347 @@ +# Collections and Composition + +Nested validators, slice and map property rules, slice pointers, and validator variants derived from path removal. + +## Topics + +- [Compose nested validators](#compose-nested-validators) + - [Include a validator for a nested object.](#include-a-validator-for-a-nested-object) +- [Validate collection elements](#validate-collection-elements) + - [Validate a slice and each of its elements.](#validate-a-slice-and-each-of-its-elements) + - [Handle slices whose elements are pointers.](#handle-slices-whose-elements-are-pointers) + - [Validate map keys, values, and key-value items.](#validate-map-keys-values-and-key-value-items) +- [Derive validator variants](#derive-validator-variants) + - [Remove selected properties by path.](#remove-selected-properties-by-path) + +## Compose nested validators + +Use Include when a property has its own validator. Govy appends nested paths automatically so errors still point to the leaf property. + + + +**Include a validator for a nested object.** + +[//]: # (embed: ExamplePropertyRules_Include) + +```go +// So far we've defined validation rules for simple, top-level properties. +// What If we want to define validation rules for nested properties? +// We can use [govy.PropertyRules.Include] to include another [govy.Validator] in our [govy.PropertyRules]. +// +// Let's extend our [Teacher] struct to include a nested [University] property. +// [University] in of itself is another struct with its own validation rules. +// +// Notice how the nested property path is automatically built for you, +// each segment separated by a dot. +func ExamplePropertyRules_Include() { + universityValidation := govy.New( + govy.For(func(u University) string { return u.Address }). + WithName("address"). + Required(), + ) + teacherValidation := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(rules.EQ("Tom")), + govy.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(universityValidation), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jerry", + Age: 51 * year, + University: University{ + Name: "Poznan University of Technology", + Address: "", + }, + } + + err := teacherValidation.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jerry': + // - must be equal to 'Tom' + // - 'university.address': + // - property is required but was empty +} +``` + +## Validate collection elements + +Use collection-specific builders when both the collection and each element need validation. They preserve map keys or slice indexes in paths. + + + +**Validate a slice and each of its elements.** + +[//]: # (embed: ExampleForSlice) + +```go +// When dealing with slices we often want to both validate the whole slice +// and each of its elements. +// You can use [govy.ForSlice] function to do just that. +// It returns a new struct [govy.PropertyRulesForSlice] which behaves exactly +// the same as [govy.PropertyRules], but extends its API slightly. +// +// To define rules for each element use: +// - [govy.PropertyRulesForSlice.RulesForEach] +// - [govy.PropertyRulesForSlice.IncludeForEach] +// +// These work exactly the same way as [govy.PropertyRules.Rules] and [govy.PropertyRules.Include] +// verifying each slice element. +// +// [govy.PropertyRulesForSlice.Rules] is in turn used to define rules for the whole slice. +// +// Note: [govy.PropertyRulesForSlice] does not implement Include function for the whole slice. +// +// In the below example, we're defining that students slice must have at most 2 elements +// and that each element's index must be unique. +// For each element we're also including [Student] [govy.Validator]. +// Notice that property path for slices has the following format: +// []. +func ExampleForSlice() { + studentValidator := govy.New( + govy.For(func(s Student) string { return s.Index }). + WithName("index"). + Rules(rules.StringLength(9, 9)), + ) + teacherValidator := govy.New( + govy.ForSlice(func(t Teacher) []Student { return t.Students }). + WithName("students"). + Rules( + rules.SliceMaxLength[[]Student](2), + rules.SliceUnique(func(v Student) string { return v.Index })). + IncludeForEach(studentValidator), + ).When(func(t Teacher) bool { return t.Age < 50 }) + + teacher := Teacher{ + Name: "John", + Students: []Student{ + {Index: "918230014"}, + {Index: "9182300123"}, + {Index: "918230014"}, + }, + } + + err := teacherValidator.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed for the following properties: + // - 'students' with value '[{"index":"918230014"},{"index":"9182300123"},{"index":"918230014"}]': + // - length must be less than or equal to 2 + // - elements are not unique, 1st and 3rd elements collide + // - 'students[1].index' with value '9182300123': + // - length must be between 9 and 9 +} +``` + + + +**Handle slices whose elements are pointers.** + +[//]: # (embed: ExampleForSlice_sliceOfPointers) + +```go +// When dealing with slices of pointers you may find it problematic to add [govy.Rule] +// with [govy.PropertyRulesForSlice.RulesForEach]. +// The builtin rules, and most likely your custom rules as well, all operate on non-pointer values. +// This means you cannot use them on your slice's pointer elements. +// +// To solve this problem you can use [govy.ForPointer] constructor and convert any [govy.Rule] +// to work on pointers. +// +// In the below example we're defining two [govy.Validator] instances: +// - 'faultyValidator' which will not fail for 'nil' value +// - 'goodValidator' which will fail for 'nil' value by using [rules.Required] rule +// +// This behavior is consistent with [govy.ForPointer] constructor, which will skip the validation +// unless you add [govy.PropertyRules.Required] to enforce the value to be a non-nil pointer. +func ExampleForSlice_sliceOfPointers() { + type Pointers struct { + Pointers []*string `json:"pointers"` + } + pointersRules := govy.ForSlice(func(p Pointers) []*string { return p.Pointers }). + WithName("pointers"). + Rules(rules.SliceMaxLength[[]*string](2)). + RulesForEach( + govy.RuleToPointer(rules.StringLength(9, 9)), + ) + faultyValidator := govy.New( + pointersRules, + ) + goodValidator := govy.New( + pointersRules.RulesForEach(rules.Required[*string]()), + ) + + pointers := Pointers{ + Pointers: []*string{ptr("918230014"), ptr("9182300123"), ptr("918230014"), nil}, + } + + err := faultyValidator.Validate(pointers) + if err != nil { + fmt.Println(err) + } + err = goodValidator.Validate(pointers) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed for the following properties: + // - 'pointers' with value '["918230014","9182300123","918230014",null]': + // - length must be less than or equal to 2 + // - 'pointers[1]' with value '9182300123': + // - length must be between 9 and 9 + // Validation has failed for the following properties: + // - 'pointers' with value '["918230014","9182300123","918230014",null]': + // - length must be less than or equal to 2 + // - 'pointers[1]' with value '9182300123': + // - length must be between 9 and 9 + // - 'pointers[3]': + // - property is required but was empty +} +``` + + + +**Validate map keys, values, and key-value items.** + +[//]: # (embed: ExampleForMap) + +```go +// When dealing with maps there are three forms of iteration: +// - keys +// - values +// - key-value pairs (items) +// +// You can use [govy.ForMap] function to define rules for all the aforementioned iterators. +// It returns a new struct [govy.PropertyRulesForMap] which behaves similar to +// [govy.PropertyRulesForSlice]. +// +// To define rules for keys use: +// - [govy.PropertyRulesForMap.RulesForKeys] +// - [govy.PropertyRulesForMap.IncludeForKeys] +// - [govy.PropertyRulesForMap.RulesForValues] +// - [govy.PropertyRulesForMap.IncludeForValues] +// - [govy.PropertyRulesForMap.RulesForItems] +// - [govy.PropertyRulesForMap.IncludeForItems] +// +// These work exactly the same way as [govy.PropertyRules.Rules] and [govy.PropertyRules.Include] +// verifying each map's key, value or [govy.MapItem]. +// +// [govy.PropertyRulesForMap.Rules] is in turn used to define rules for the whole map. +// +// Note: [govy.PropertyRulesForMap] does not implement Include function for the whole map. +// +// In the below example, we're defining that student index to [Teacher] map: +// - Must have at most 2 elements (map). +// - Keys must have a length of 9 (keys). +// - Eve cannot be a teacher for any student (values). +// - Joan cannot be a teacher for student with index 918230013 (items). +// +// Notice that property path for maps has the following format: +// []. +func ExampleForMap() { + teacherValidator := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(rules.NEQ("Eve")), + ) + tutoringValidator := govy.New( + govy.ForMap(func(t Tutoring) map[string]Teacher { return t.StudentIndexToTeacher }). + WithName("students"). + Rules( + rules.MapMaxLength[map[string]Teacher](2), + ). + RulesForKeys( + rules.StringLength(9, 9), + ). + IncludeForValues(teacherValidator). + RulesForItems(govy.NewRule(func(v govy.MapItem[string, Teacher]) error { + if v.Key == "918230013" && v.Value.Name == "Joan" { + return govy.NewRuleError( + "Joan cannot be a teacher for student with index 918230013", + "joan_teacher", + ) + } + return nil + })), + ) + + tutoring := Tutoring{ + StudentIndexToTeacher: map[string]Teacher{ + "918230013": {Name: "Joan"}, + "9182300123": {Name: "Eve"}, + "918230014": {Name: "Joan"}, + }, + } + + err := tutoringValidator.Validate(tutoring) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed for the following properties: + // - 'students' with value '{"9182300123":{"name":"Eve","age":0,"students":null,"university":{"name":"","address":""}},"91823001...': + // - length must be less than or equal to 2 + // - 'students['9182300123']' with key '9182300123': + // - length must be between 9 and 9 + // - 'students['9182300123'].name' with value 'Eve': + // - must not be equal to 'Eve' + // - 'students['918230013']' with value '{"name":"Joan","age":0,"students":null,"university":{"name":"","address":""}}': + // - Joan cannot be a teacher for student with index 918230013 +} +``` + +## Derive validator variants + +Use path-based removal when a caller needs a variant of an existing validator without mutating the original declaration. + + + +**Remove selected properties by path.** + +[//]: # (embed: ExampleValidator_RemovePropertiesByPath) + +```go +// This example demonstrates how to remove specific properties from a [govy.Validator] by their paths. +// This is useful when you want to create a modified validator without certain rules. +func ExampleValidator_RemovePropertiesByPath() { + baseValidator := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(rules.StringNotEmpty()), + govy.For(func(t Teacher) time.Duration { return t.Age }). + WithName("age"). + Rules(rules.GT(time.Duration(0))), + ) + + teacher := Teacher{Name: "John", Age: -1} + + // Base validator fails because age is negative + err := baseValidator.Validate(teacher) + if err != nil { + fmt.Println("Base validator failed") + } + + // Modified validator passes because age validation is removed + modifiedValidator := baseValidator.RemovePropertiesByPath(jsonpath.New().Name("age")) + err = modifiedValidator.Validate(teacher) + if err == nil { + fmt.Println("Modified validator passed") + } + + // Output: + // Base validator failed + // Modified validator passed +} +``` diff --git a/.agents/skills/govy/references/core-validation.md b/.agents/skills/govy/references/core-validation.md new file mode 100644 index 00000000..2933345a --- /dev/null +++ b/.agents/skills/govy/references/core-validation.md @@ -0,0 +1,447 @@ +# Core Validation + +Validator construction, naming, conditions, slice validation, cascade behavior, and validator composition patterns. + +## Topics + +- [Construct reusable validators](#construct-reusable-validators) + - [Create a validator from property rules.](#create-a-validator-from-property-rules) + - [Attach a static validator name.](#attach-a-static-validator-name) + - [Compute the validator name from the validated value.](#compute-the-validator-name-from-the-validated-value) +- [Control when validators run](#control-when-validators-run) + - [Run a validator only when a predicate matches.](#run-a-validator-only-when-a-predicate-matches) + - [Branch validation by including different validators under property conditions.](#branch-validation-by-including-different-validators-under-property-conditions) +- [Validate slices](#validate-slices) + - [Validate a slice while preserving indexed property paths.](#validate-a-slice-while-preserving-indexed-property-paths) + - [Validate each element directly with ValidateSlice.](#validate-each-element-directly-with-validateslice) +- [Control error aggregation](#control-error-aggregation) + - [Stop evaluating later properties after a validator failure.](#stop-evaluating-later-properties-after-a-validator-failure) +- [Compose a complete validator](#compose-a-complete-validator) + - [Build a full Teacher validator.](#build-a-full-teacher-validator) + +## Construct reusable validators + +Define validators once and reuse them. Name validators when the resulting error should identify the validated entity. + + + +**Create a validator from property rules.** + +[//]: # (embed: ExampleNew) + +```go +// In order to create a new [govy.Validator] use [govy.New] constructor. +// Let's define simple [govy.PropertyRules] for [Teacher.Name]. +// For now, it will be always failing. +func ExampleNew() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + Rules(govy.NewRule(func(name string) error { return fmt.Errorf("always fails") })), + ) + + err := v.Validate(Teacher{}) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed: + // - always fails +} +``` + + + +**Attach a static validator name.** + +[//]: # (embed: ExampleValidator_WithName) + +```go +// To associate [govy.Validator] with an entity name use [govy.Validator.WithName] function. +// When any of the rules fails, the error will contain the entity name you've provided. +func ExampleValidator_WithName() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + Rules(govy.NewRule(func(name string) error { return fmt.Errorf("always fails") })), + ).WithName("Teacher") + + err := v.Validate(Teacher{}) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed: + // - always fails +} +``` + + + +**Compute the validator name from the validated value.** + +[//]: # (embed: ExampleValidator_WithNameFunc) + +```go +// If statically defined name through [govy.Validator.WithName] is not enough, +// you can use [govy.Validator.WithNameFunc]. +// The function receives the entity's instance you're validating and returns a string name. +func ExampleValidator_WithNameFunc() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + Rules(govy.NewRule(func(name string) error { return fmt.Errorf("always fails") })), + ).WithNameFunc(func(t Teacher) string { return "Teacher " + t.Name }) + + err := v.Validate(Teacher{Name: "John"}) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher John has failed: + // - always fails +} +``` + +## Control when validators run + +Use validator-level conditions for whole-object gates. Use property-level conditions when only one property branch should be skipped. + + + +**Run a validator only when a predicate matches.** + +[//]: # (embed: ExampleValidator_When) + +```go +// [govy.Validator] rules can be evaluated on condition, to specify the predicate use [govy.Validator.When] function. +// +// In this example, validation for [Teacher] instance will only be evaluated +// if the [Teacher.Age] property is less than 50 years. +func ExampleValidator_When() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + Rules(govy.NewRule(func(name string) error { return fmt.Errorf("always fails") })), + ). + When(func(t Teacher) bool { return t.Age < (50 * year) }) + + // Prepare teachers. + teacherTom := Teacher{ + Name: "Tom", + Age: 51 * year, + } + teacherJerry := Teacher{ + Name: "Jerry", + Age: 30 * year, + } + + // Run validation. + err := v.Validate(teacherTom) + if err != nil { + fmt.Println(err.(*govy.ValidatorError).WithName("Tom")) + } + err = v.Validate(teacherJerry) + if err != nil { + fmt.Println(err.(*govy.ValidatorError).WithName("Jerry")) + } + + // Output: + // Validation for Jerry has failed: + // - always fails +} +``` + + + +**Branch validation by including different validators under property conditions.** + +[//]: # (embed: ExampleValidator_branchingPattern) + +```go +// When dealing with properties that should only be validated if a certain other +// property has specific value, it's recommended to use [govy.PropertyRules.When] and [govy.PropertyRules.Include] +// to separate validation paths into non-overlapping branches. +// +// Notice how in the below example [File.Format] is the common, +// shared property between [CSV] and [JSON] files. +// We define separate [govy.Validator] for [CSV] and [JSON] and use [govy.PropertyRules.When] to only validate +// their included [govy.Validator] if the correct [File.Format] is provided. +func ExampleValidator_branchingPattern() { + type ( + CSV struct { + Separator string `json:"separator"` + } + JSON struct { + Indent string `json:"indent"` + } + File struct { + Format string `json:"format"` + CSV *CSV `json:"csv,omitempty"` + JSON *JSON `json:"json,omitempty"` + } + ) + + csvValidation := govy.New( + govy.For(func(c CSV) string { return c.Separator }). + WithName("separator"). + Required(). + Rules(rules.OneOf(",", ";")), + ) + + jsonValidation := govy.New( + govy.For(func(j JSON) string { return j.Indent }). + WithName("indent"). + Required(). + Rules(rules.StringMatchRegexp(regexp.MustCompile(`^\s*$`))), + ) + + fileValidation := govy.New( + govy.ForPointer(func(f File) *CSV { return f.CSV }). + When(func(f File) bool { return f.Format == "csv" }). + Include(csvValidation), + govy.ForPointer(func(f File) *JSON { return f.JSON }). + When(func(f File) bool { return f.Format == "json" }). + Include(jsonValidation), + govy.For(func(f File) string { return f.Format }). + WithName("format"). + Required(). + Rules(rules.OneOf("csv", "json")), + ).WithName("File") + + file := File{ + Format: "json", + CSV: nil, + JSON: &JSON{ + Indent: "invalid", + }, + } + + err := fileValidation.Validate(file) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for File has failed for the following properties: + // - 'indent' with value 'invalid': + // - string must match regular expression: '^\s*$' +} +``` + +## Validate slices + +Use ForSlice when the slice itself has rules or the path should include indexes. Use ValidateSlice when each value can be validated independently. + + + +**Validate a slice while preserving indexed property paths.** + +[//]: # (embed: ExampleValidator_Validate_slice) + +```go +// If you want to validate a slice of entities, you can combine [govy.New] with [govy.ForSlice]. +// The produced errors will contain information about the failing entity's index +// in their [govy.PropertyError.PropertyPath]. +func ExampleValidator_Validate_slice() { + teacherValidator := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(govy.NewRule(func(name string) error { return fmt.Errorf("always fails") })), + ) + v := govy.New( + govy.ForSlice(govy.GetSelf[[]Teacher]()). + IncludeForEach(teacherValidator), + ) + + err := v.Validate([]Teacher{ + {Name: "John"}, + {Name: "Jake"}, + }) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed for the following properties: + // - '[0].name' with value 'John': + // - always fails + // - '[1].name' with value 'Jake': + // - always fails +} +``` + + + +**Validate each element directly with ValidateSlice.** + +[//]: # (embed: ExampleValidator_ValidateSlice) + +```go +// If combining [govy.New] with [govy.ForSlice] is not verbose enough for you, +// you can use [govy.Validator.ValidateSlice] function. +// It will validate each element according to the rules defined by [govy.Validator]. +// It returns [govy.ValidatorErrors]. +// +// Note: If you need to perform additional validation on the whole slice, +// you should rather use [govy.New] with [govy.ForSlice] and [govy.GetSelf]. +// [govy.Validator.ValidateSlice] is designed to be used for processing independent values. +// +// Note: Since each element is validated in isolation, +// the reported property paths will not start with the slice index, +// they will instead start at the element's root. +func ExampleValidator_ValidateSlice() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(govy.NewRule(func(name string) error { return fmt.Errorf("always fails") })), + ).WithName("Teacher") + + err := v.ValidateSlice([]Teacher{ + {Name: "John"}, + {Name: "Jake"}, + }) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher at index 0 has failed for the following properties: + // - 'name' with value 'John': + // - always fails + // Validation for Teacher at index 1 has failed for the following properties: + // - 'name' with value 'Jake': + // - always fails +} +``` + +## Control error aggregation + +Cascade settings decide whether validation continues after failures. Validator cascade applies across properties unless a property-level cascade overrides it. + + + +**Stop evaluating later properties after a validator failure.** + +[//]: # (embed: ExampleValidator_Cascade) + +```go +// Unlike [govy.PropertyRules.Cascade] which works on [govy.PropertyRules] level, +// [govy.Validator.Cascade] propagates to all the properties of [govy.Validator] and +// furthermore, will stop evaluating the next property if any preceding property fails. +// +// If [govy.PropertyRules.Cascade] is set, the setting will take precedence over +// [govy.Validator] cascade mode. +// +// See [ExamplePropertyRules_Cascade] for more details on [govy.PropertyRules.Cascade]. +func ExampleValidator_Cascade() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Cascade(govy.CascadeModeContinue). + Rules(rules.NEQ("Jerry")). + Rules(rules.EQ("Tom")), + govy.For(func(t Teacher) time.Duration { return t.Age }). + WithName("age"). + Rules( + rules.GT(18*year), + govy.NewRule(func(time.Duration) error { + return fmt.Errorf("always fails") + }), + ), + ). + Cascade(govy.CascadeModeStop) + + for _, name := range []string{"Tom", "Jerry"} { + teacher := Teacher{ + Name: name, + Age: 17 * year, + } + err := v.WithName(name).Validate(teacher) + if err != nil { + fmt.Println(err) + } + } + + // Output: + // Validation for Tom has failed for the following properties: + // - 'age' with value '148920h0m0s': + // - must be greater than '157680h0m0s' + // Validation for Jerry has failed for the following properties: + // - 'name' with value 'Jerry': + // - must not be equal to 'Jerry' + // - must be equal to 'Tom' +} +``` + +## Compose a complete validator + +Use the complete example as a reference shape for idiomatic validator declarations with named properties, builtin rules, and nested composition. + + + +**Build a full Teacher validator.** + +[//]: # (embed: ExampleValidator) + +```go +// Bringing it all (mostly) together, let's create a fully fledged [govy.Validator] for [Teacher]. +func ExampleValidator() { + universityValidation := govy.New( + govy.For(func(u University) string { return u.Address }). + WithName("address"). + Required(), + ) + studentValidator := govy.New( + govy.For(func(s Student) string { return s.Index }). + WithName("index"). + Rules(rules.StringLength(9, 9)), + ) + teacherValidator := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules( + rules.StringNotEmpty(), + rules.OneOf("Jake", "George")), + govy.ForSlice(func(t Teacher) []Student { return t.Students }). + WithName("students"). + Rules( + rules.SliceMaxLength[[]Student](2), + rules.SliceUnique(func(v Student) string { return v.Index })). + IncludeForEach(studentValidator), + govy.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(universityValidation), + ).When(func(t Teacher) bool { return t.Age < 50 }) + + teacher := Teacher{ + Name: "John", + Students: []Student{ + {Index: "918230014"}, + {Index: "9182300123"}, + {Index: "918230014"}, + }, + University: University{ + Name: "Poznan University of Technology", + Address: "", + }, + } + + err := teacherValidator.WithName("John").Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for John has failed for the following properties: + // - 'name' with value 'John': + // - must be one of: Jake, George + // - 'students' with value '[{"index":"918230014"},{"index":"9182300123"},{"index":"918230014"}]': + // - length must be less than or equal to 2 + // - elements are not unique, 1st and 3rd elements collide + // - 'students[1].index' with value '9182300123': + // - length must be between 9 and 9 + // - 'university.address': + // - property is required but was empty +} +``` diff --git a/.agents/skills/govy/references/errors.md b/.agents/skills/govy/references/errors.md new file mode 100644 index 00000000..94fbbd78 --- /dev/null +++ b/.agents/skills/govy/references/errors.md @@ -0,0 +1,157 @@ +# Errors + +Validator errors, runtime error naming, structured JSON errors, and manually constructed property errors. + +## Topics + +- [Work with validator errors](#work-with-validator-errors) + - [Set or overwrite a validator name on returned errors.](#set-or-overwrite-a-validator-name-on-returned-errors) + - [Inspect and serialize validator error output.](#inspect-and-serialize-validator-error-output) +- [Create property-scoped errors](#create-property-scoped-errors) + - [Construct a property error with one or more rule errors.](#construct-a-property-error-with-one-or-more-rule-errors) + +## Work with validator errors + +Use validator errors when validation failed at the validator level. Names can be attached after validation, and structured fields are available for serialization or inspection. + + + +**Set or overwrite a validator name on returned errors.** + +[//]: # (embed: ExampleValidatorError_WithName) + +```go +// You can also add [govy.Validator] name during runtime, +// by calling [govy.ValidatorError.WithName] function on the returned error. +// +// Note: We left the previous "Teacher" name assignment, to demonstrate that +// the [govy.ValidatorError.WithName] function call will overwrite it. +// +// Note: This would also work: +// +// err := v.WithName("Jake").Validate(Teacher{}) +// +// govy, excluding error handling, tries to follow immutability principle. +// Calling any method on [govy.Validator] will not change its declared instance, +// but rather create a copy of it. +func ExampleValidatorError_WithName() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + Rules(govy.NewRule(func(name string) error { return fmt.Errorf("always fails") })), + ).WithName("Teacher") + + err := v.Validate(Teacher{}) + if err != nil { + fmt.Println(err.(*govy.ValidatorError).WithName("Jake")) + } + + // Output: + // Validation for Jake has failed: + // - always fails +} +``` + + + +**Inspect and serialize validator error output.** + +[//]: # (embed: ExampleValidatorError) + +```go +// All errors returned by [govy.Validator] are of type [govy.ValidatorError]. +// Type casting directly to [govy.ValidatorError] should be safe once an error +// was asserted to be non-nil. +// However, you shouldn't trust any API with such promises, and always type check in your +// type assignments. +// +// All error types return by govy are JSON serializable. +func ExampleValidatorError() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + Rules(govy.NewRule(func(name string) error { return fmt.Errorf("always fails") })). + WithName("name"), + ).WithName("Teacher") + + err := v.Validate(Teacher{Name: "John"}) + if err != nil { + if validatorErr, ok := err.(*govy.ValidatorError); ok { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err = enc.Encode(validatorErr); err != nil { + fmt.Printf("error encoding: %v\n", err) + } + } + } + + // Output: + // { + // "errors": [ + // { + // "propertyPath": "name", + // "propertyValue": "John", + // "errors": [ + // { + // "error": "always fails" + // } + // ] + // } + // ], + // "name": "Teacher" + // } +} +``` + +## Create property-scoped errors + +Return property errors from custom rules when top-level validation logic needs to point the failure at a nested property path. + + + +**Construct a property error with one or more rule errors.** + +[//]: # (embed: ExampleNewPropertyError) + +```go +// Sometimes you need top level context, +// but you want to scope the error to a specific, nested property. +// One of the ways to do that is to use [govy.NewPropertyError] +// and return [govy.PropertyError] from your validation rule. +// Note that you can still use [govy.ErrorCode] and pass [govy.RuleError] to the constructor. +// You can pass any number of [govy.RuleError]. +func ExampleNewPropertyError() { + v := govy.New( + govy.For(govy.GetSelf[Teacher]()). + Rules(govy.NewRule(func(t Teacher) error { + if t.Name == "Jake" { + return govy.NewPropertyError( + jsonpath.Parse("name"), + t.Name, + govy.NewRuleError("name cannot be Jake", "error_code_jake"), + govy.NewRuleError("you can pass me too!")) + } + return nil + })), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + propertyErrors := err.(*govy.ValidatorError).Errors + ruleErrors := propertyErrors[0].Errors + fmt.Printf("Error code: %s\n\n", ruleErrors[0].Code) + fmt.Println(err) + } + + // Output: + // Error code: error_code_jake + // + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jake': + // - name cannot be Jake + // - you can pass me too! +} +``` diff --git a/.agents/skills/govy/references/existing-rules.md b/.agents/skills/govy/references/existing-rules.md new file mode 100644 index 00000000..d49b4f02 --- /dev/null +++ b/.agents/skills/govy/references/existing-rules.md @@ -0,0 +1,81 @@ +# Existing Rules + +Generated reference of exported predefined rule constructors from `pkg/rules`. +Use this when choosing an existing rule before writing a custom one. + +[//]: # (docs: pkg/rules?kind=func&returns=govy.Rule,govy.RuleSet) + +- `DurationPrecision` - DurationPrecision ensures the duration is defined with the specified precision. +- `EQ` - EQ ensures the property's value is equal to the compared value. +- `EqualProperties` - EqualProperties checks if all the specified properties are equal. +- `Forbidden` - Forbidden ensures the property's value is its type's zero value, i.e. it's empty. +- `GT` - GT ensures the property's value is greater than the compared value. +- `GTComparableProperties` - GTComparableProperties ensures the first property's value is greater than the second property's value. +- `GTE` - GTE ensures the property's value is greater than or equal to the compared value. +- `GTEComparableProperties` - GTEComparableProperties ensures the first property's value is greater than or equal to the second property's value. +- `GTEProperties` - GTEProperties ensures the first property's value is greater than or equal to the second property's value. +- `GTProperties` - GTProperties ensures the first property's value is greater than the second property's value. +- `LT` - LT ensures the property's value is less than the compared value. +- `LTComparableProperties` - LTComparableProperties ensures the first property's value is less than the second property's value. +- `LTE` - LTE ensures the property's value is less than or equal to the compared value. +- `LTEComparableProperties` - LTEComparableProperties ensures the first property's value is less than or equal to the second property's value. +- `LTEProperties` - LTEProperties ensures the first property's value is less than or equal to the second property's value. +- `LTProperties` - LTProperties ensures the first property's value is less than the second property's value. +- `MapLength` - MapLength ensures the map's length is between min and max (closed interval). +- `MapMaxLength` - MapMaxLength ensures the map's length is less than or equal to the limit. +- `MapMinLength` - MapMinLength ensures the map's length is greater than or equal to the limit. +- `MutuallyDependent` - MutuallyDependent checks if properties are mutually dependent. +- `MutuallyExclusive` - MutuallyExclusive checks if properties are mutually exclusive. +- `NEQ` - NEQ ensures the property's value is not equal to the compared value. +- `NotOneOf` - NotOneOf checks if the property's value does not match any of the provided values. +- `OneOf` - OneOf checks if the property's value matches one of the provided values. +- `OneOfProperties` - OneOfProperties checks if at least one of the properties is set. +- `Required` - Required ensures the property's value is not empty (i.e. it's not its type's zero value). +- `SliceLength` - SliceLength ensures the slice's length is between min and max (closed interval). +- `SliceMaxLength` - SliceMaxLength ensures the slice's length is less than or equal to the limit. +- `SliceMinLength` - SliceMinLength ensures the slice's length is greater than or equal to the limit. +- `SliceUnique` - SliceUnique ensures that a slice contains unique elements based on a provided [HashFunction]. +- `StringASCII` - StringASCII ensures property's value contains only ASCII characters. +- `StringAlpha` - StringAlpha ensures the property's value consists only of ASCII letters. +- `StringAlphaUnicode` - StringAlphaUnicode ensures the property's value consists only of Unicode letters. +- `StringAlphanumeric` - StringAlphanumeric ensures the property's value consists only of ASCII letters and numbers. +- `StringAlphanumericUnicode` - StringAlphanumericUnicode ensures the property's value consists only of Unicode letters and numbers. +- `StringCIDR` - StringCIDR ensures property's value is a valid CIDR notation IP address. +- `StringCIDRv4` - StringCIDRv4 ensures property's value is a valid CIDR notation IPv4 address. +- `StringCIDRv6` - StringCIDRv6 ensures property's value is a valid CIDR notation IPv6 address. +- `StringContains` - StringContains ensures the property's value contains all the provided substrings. +- `StringCrontab` - StringCrontab ensures the property's value is a valid crontab schedule expression. +- `StringDNSLabel` - StringDNSLabel ensures the property's value is a valid DNS label as defined by [RFC 1123]. +- `StringDNSSubdomain` - StringDNSSubdomain ensures the property's value is a valid DNS subdomain as defined by [RFC 1123]. +- `StringDateTime` - StringDateTime ensures the property's value is a valid date and time in the specified layout. +- `StringDenyRegexp` - StringDenyRegexp ensures the property's value does not match the regular expression. +- `StringDirPath` - StringDirPath ensures the property's value is a file system path pointing to an existing directory. +- `StringEmail` - StringEmail ensures the property's value is a valid email address. +- `StringEndsWith` - StringEndsWith ensures the property's value ends with one of the provided suffixes. +- `StringExcludes` - StringExcludes ensures the property's value does not contain any of the provided substrings. +- `StringFQDN` - StringFQDN ensures the property's value is a fully qualified domain name (FQDN). +- `StringFilePath` - StringFilePath ensures the property's value is a file system path pointing to an existing file. +- `StringFileSystemPath` - StringFileSystemPath ensures the property's value is an existing file system path. +- `StringGitRef` - StringGitRef ensures a git reference name follows the [git-check-ref-format] rules. +- `StringIP` - StringIP ensures property's value is a valid IP address. +- `StringIPv4` - StringIPv4 ensures property's value is a valid IPv4 address. +- `StringIPv6` - StringIPv6 ensures property's value is a valid IPv6 address. +- `StringJSON` - StringJSON ensures property's value is a valid JSON literal. +- `StringKubernetesQualifiedName` - StringKubernetesQualifiedName ensures the property's value is a valid "qualified name" as defined by [Kubernetes validation]. +- `StringLength` - StringLength ensures the string's length is between min and max (closed interval). +- `StringMAC` - StringMAC ensures property's value is a valid MAC address. +- `StringMatchFileSystemPath` - StringMatchFileSystemPath ensures the property's value matches the provided file path pattern. +- `StringMatchRegexp` - StringMatchRegexp ensures the property's value matches the regular expression. +- `StringMaxLength` - StringMaxLength ensures the string's length is less than or equal to the limit. +- `StringMinLength` - StringMinLength ensures the string's length is greater than or equal to the limit. +- `StringNotEmpty` - StringNotEmpty ensures the property's value is not empty. +- `StringRegexp` - StringRegexp ensures the property's value is a valid regular expression. +- `StringStartsWith` - StringStartsWith ensures the property's value starts with one of the provided prefixes. +- `StringTimeZone` - StringTimeZone ensures the property's value is a valid time zone name which uniquely identifies a time zone in the IANA Time Zone database. +- `StringTitle` - StringTitle ensures each word in a string starts with a capital letter. +- `StringURL` - StringURL ensures property's value is a valid URL as defined by [url.Parse] function. +- `StringUUID` - StringUUID ensures property's value is a valid UUID string as defined by [RFC 4122]. +- `URL` - URL ensures the URL is valid. +- `UniqueProperties` - UniqueProperties ensures each property is unique based on a provided [HashFunction]. + +[//]: # (end-docs) diff --git a/.agents/skills/govy/references/govytest.md b/.agents/skills/govy/references/govytest.md new file mode 100644 index 00000000..4456d8f9 --- /dev/null +++ b/.agents/skills/govy/references/govytest.md @@ -0,0 +1,374 @@ +# Govytest + +Assertion helpers for validating govy errors in tests. + +## Topics + +- [Assert validation results](#assert-validation-results) + - [Assert that validation succeeded.](#assert-that-validation-succeeded) + - [Assert exact structured rule errors.](#assert-exact-structured-rule-errors) + - [Assert nested ValidatorErrors produced by slice validation.](#assert-nested-validatorerrors-produced-by-slice-validation) + - [Assert that one expected error is present among other errors.](#assert-that-one-expected-error-is-present-among-other-errors) + +## Assert validation results + +Use govytest when tests should match structured validation failures. Prefer these helpers over comparing full formatted error strings. + + + +**Assert that validation succeeded.** + +[//]: # (embed: ExampleAssertNoError) + +```go +// You can use [govytest.AssertNoError] to ensure no error was produced by [govy.Validator.Validate]. +// If an error was produced, it will be printed to the stdout in JSON format. +// +// To demonstrate the erroneous output of [govytest.AssertNoError] we'll fail the assertion. +func ExampleAssertNoError() { + teacherValidator := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules( + rules.StringNotEmpty(), + rules.OneOf("Jake", "George")), + govy.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(govy.New( + govy.For(func(u University) string { return u.Address }). + WithName("address"). + Required(), + )), + ) + + teacher := Teacher{ + Name: "John", + University: University{ + Name: "Poznan University of Technology", + Address: "", + }, + } + + // We're using a mock testing.T to capture the error produced by the assertion. + mt := new(mockTestingT) + + err := teacherValidator.WithName("John").Validate(teacher) + govytest.AssertNoError(mt, err) + + // This will print the error produced by the assertion. + fmt.Println(mt.recordedError) + + // Output: + // Received unexpected error: + // { + // "errors": [ + // { + // "propertyPath": "name", + // "propertyValue": "John", + // "errors": [ + // { + // "error": "must be one of: Jake, George", + // "code": "one_of", + // "description": "must be one of: Jake, George" + // } + // ] + // }, + // { + // "propertyPath": "university.address", + // "errors": [ + // { + // "error": "property is required but was empty", + // "code": "required" + // } + // ] + // } + // ], + // "name": "John" + // } +} +``` + + + +**Assert exact structured rule errors.** + +[//]: # (embed: ExampleAssertError) + +```go +// Verifying that expected errors were produced by [govy.Validator.Validate] can be a tedious task. +// Often times we might only care about [govy.ErrorCode] and not the message or description of the error. +// To help in that process, [govytest.AssertError] can be used to ensure that the expected errors were produced. +// It accepts multiple [govytest.ExpectedRuleError], each being a short and concise +// representation of the error we're expecting to occur. +// For more details on how to use [govytest.ExpectedRuleError], see its code documentation. +// +// To demonstrate the erroneous output of [govytest.AssertError] we'll fail the assertion. +func ExampleAssertError() { + teacherValidator := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules( + rules.StringNotEmpty(), + rules.OneOf("Jake", "George")), + govy.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(govy.New( + govy.For(func(u University) string { return u.Address }). + WithName("address"). + Required(), + )), + ) + + teacher := Teacher{ + Name: "John", + University: University{ + Name: "Poznan University of Technology", + Address: "", + }, + } + + // We're using a mock testing.T to capture the error produced by the assertion. + mt := new(mockTestingT) + + err := teacherValidator.WithName("John").Validate(teacher) + govytest.AssertError(mt, err, + govytest.ExpectedRuleError{ + PropertyPath: "name", + ContainsMessage: "one of", + }, + govytest.ExpectedRuleError{ + PropertyPath: "university.address", + Code: "greater_than", + }, + ) + + // This will print the error produced by the assertion. + fmt.Println(mt.recordedError) + + // Output: + // Expected error was not found. + // EXPECTED: + // { + // "propertyPath": "university.address", + // "code": "greater_than" + // } + // ACTUAL: + // [ + // { + // "propertyPath": "name", + // "propertyValue": "John", + // "errors": [ + // { + // "error": "must be one of: Jake, George", + // "code": "one_of", + // "description": "must be one of: Jake, George" + // } + // ] + // }, + // { + // "propertyPath": "university.address", + // "errors": [ + // { + // "error": "property is required but was empty", + // "code": "required" + // } + // ] + // } + // ] +} +``` + + + +**Assert nested ValidatorErrors produced by slice validation.** + +[//]: # (embed: ExampleAssertError_validatorErrors) + +```go +// [govytest.AssertError] can handle not only [govy.ValidatorError] but also a slice of these +// wrapped into [govy.ValidatorErrors]. +// +// For instance, [govy.ValidatorErrors] is returned from [govy.Validator.ValidateSlice], +// you can also choose to construct this slice type yourself. +// In any case, in order to match [govytest.ExpectedRuleError] the assertion function needs +// to first match it with the right [govy.ValidatorError]. +// +// In order to achieve that you need to set the following fields: +// - [govytest.ExpectedRuleError.ValidatorName] which matches [govy.ValidatorError.Name] +// - [govytest.ExpectedRuleError.ValidatorIndex] which matches [govy.ValidatorError.SliceIndex] +// +// You can set them both, but you need to provide at least one of them. +// +// Every [govytest.ExpectedRuleError] is aggregated per matched [govy.ValidatorError] +// and the function runs recursively for every [govy.ValidatorError] and expected errors pair. +func ExampleAssertError_validatorErrors() { + teacherValidator := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules( + rules.StringNotEmpty(), + rules.OneOf("Eve", "George")), + govy.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(govy.New( + govy.For(func(u University) string { return u.Address }). + WithName("address"). + Required(), + )), + ) + + teacherEve := Teacher{ + Name: "Eve", + University: University{ + Name: "Poznan University of Technology", + Address: "", + }, + } + teacherJohn := Teacher{ + Name: "John", + University: University{ + Name: "Poznan University of Technology", + Address: "Some address", + }, + } + teachers := []Teacher{teacherEve, teacherJohn} + + // We're using a mock testing.T to capture the error produced by the assertion. + mt := new(mockTestingT) + + err := teacherValidator.WithNameFunc(func(s Teacher) string { return s.Name }).ValidateSlice(teachers) + govytest.AssertError(mt, err, + govytest.ExpectedRuleError{ + PropertyPath: "university.address", + Code: "greater_than", + ValidatorName: "Eve", + ValidatorIndex: ptr(0), + }, + govytest.ExpectedRuleError{ + PropertyPath: "name", + ContainsMessage: "one of", + ValidatorName: "John", + ValidatorIndex: ptr(1), + }, + ) + + // This will print the error produced by the assertion. + fmt.Println(mt.recordedError) + + // Output: + // Expected error was not found. + // EXPECTED: + // { + // "propertyPath": "university.address", + // "code": "greater_than", + // "validatorName": "Eve", + // "validatorIndex": 0 + // } + // ACTUAL: + // [ + // { + // "propertyPath": "university.address", + // "errors": [ + // { + // "error": "property is required but was empty", + // "code": "required" + // } + // ] + // } + // ] +} +``` + + + +**Assert that one expected error is present among other errors.** + +[//]: # (embed: ExampleAssertErrorContains) + +```go +// If you don't want to verify all the errors returned by [govy.Validator], +// but ensure a single, expected error is produced use [govytest.AssertErrorContains] +// instead of [govytest.AssertError]. +// +// To demonstrate the erroneous output of [govytest.AssertErrorContains] +// we'll first match the error and then fail the assertion. +func ExampleAssertErrorContains() { + teacherValidator := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules( + rules.StringNotEmpty(), + rules.OneOf("Jake", "George")), + govy.For(func(t Teacher) University { return t.University }). + WithName("university"). + Include(govy.New( + govy.For(func(u University) string { return u.Address }). + WithName("address"). + Required(), + )), + ) + + teacher := Teacher{ + Name: "John", + University: University{ + Name: "Poznan University of Technology", + Address: "", + }, + } + + // We're using a mock testing.T to capture the error produced by the assertion. + mt := new(mockTestingT) + + // Match the error. + err := teacherValidator.WithName("John").Validate(teacher) + govytest.AssertErrorContains(mt, err, govytest.ExpectedRuleError{ + PropertyPath: "name", + Code: "one_of", + }) + + // Fail to match the error. + err = teacherValidator.WithName("John").Validate(teacher) + govytest.AssertErrorContains(mt, err, govytest.ExpectedRuleError{ + PropertyPath: "university.address", + Code: "greater_than", + }) + + // This will print the error produced by the assertion. + fmt.Println(mt.recordedError) + + // Output: + // Expected error was not found. + // EXPECTED: + // { + // "propertyPath": "university.address", + // "code": "greater_than" + // } + // ACTUAL: + // [ + // { + // "propertyPath": "name", + // "propertyValue": "John", + // "errors": [ + // { + // "error": "must be one of: Jake, George", + // "code": "one_of", + // "description": "must be one of: Jake, George" + // } + // ] + // }, + // { + // "propertyPath": "university.address", + // "errors": [ + // { + // "error": "property is required but was empty", + // "code": "required" + // } + // ] + // } + // ] +} +``` diff --git a/.agents/skills/govy/references/path-inference.md b/.agents/skills/govy/references/path-inference.md new file mode 100644 index 00000000..cad30663 --- /dev/null +++ b/.agents/skills/govy/references/path-inference.md @@ -0,0 +1,192 @@ +# Path Inference + +Runtime and generated path inference, govyconfig registration, and inference caching behavior. + +## Topics + +- [Choose a path inference mode](#choose-a-path-inference-mode) + - [Configure inference modes and include test files when needed.](#configure-inference-modes-and-include-test-files-when-needed) + - [Use generated inference by registering generated paths.](#use-generated-inference-by-registering-generated-paths) +- [Avoid late inference changes](#avoid-late-inference-changes) + - [Changing inference mode after first validation does not recompute paths.](#changing-inference-mode-after-first-validation-does-not-recompute-paths) + +## Choose a path inference mode + +Prefer explicit paths unless inferred paths are part of the design. Runtime inference trades startup simplicity for a one-time AST lookup, while generated inference moves that work into code generation. + + + +**Configure inference modes and include test files when needed.** + +[//]: # (embed: ExampleInferPathMode) + +```go +// In the interactive tutorial for govy, we've been using +// [govy.PropertyRules.WithName] to provide explicit path segments for our properties. +// +// Ideally, we'd want govy to derive those paths directly from the getter expressions, +// matching the struct fields selected by the user. +// Go uses struct tags to achieve that, +// and libraries like [encoding/json] use these tags to encode/decode structs. +// Unfortunately, there's no easy way to tell what exact property we're returning from [govy.PropertyGetter]. +// +// To solve this problem, govy provides a way to infer the property path (with a catch). +// The catch being that the path inference mechanism needs to parse the whole modules' AST. +// This can be a performance hit, especially for large projects if not done properly. +// +// By default govy will not attempt to infer any property paths. +// +// So, how do we do that properly? +// Both [govy.Validator] and [govy.PropertyRules] (including variants) have a dedicated method +// to configure how property paths are inferred. +// +// It depends on the [govy.InferPathMode] used: +// - [govy.InferPathModeDisable], path inference is disabled (default), nothing to do here +// - [govy.InferPathModeRuntime], the path is inferred during runtime from the getter expression. +// This is the most flexible option, but also the slowest, although the slowdown +// is incurred only once, whenever [govy.PropertyRules.Validate] is first called. +// If you make sure that [govy.PropertyRules] is created only once and don't mind +// the one-time performance hit, this should be enough for you. +// - [govy.InferPathModeGenerate], the path is inferred during a separate code generation phase. +// This mode requires you to run `govy inferpath` before you run your code. +// It generates a file with inferred relative paths for your getter call sites, +// which automatically registers them using [govyconfig.SetInferredPath]. +// +// Since this tutorial is run as a test, +// we need to explicitly instruct govy to infer paths from test files. +// By default, test files are not parsed to improve performance. +// In order to do that, we use [govyconfig.SetInferPathIncludeTestFiles]. +func ExampleInferPathMode() { + govyconfig.SetInferPathIncludeTestFiles(true) + defer govyconfig.SetInferPathIncludeTestFiles(false) + + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + Rules(rules.EQ("Jerry")), + ). + InferPath(govy.InferPathModeRuntime). + WithName("Teacher") + + teacher := Teacher{Name: "Tom"} + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Tom': + // - must be equal to 'Jerry' +} +``` + + + +**Use generated inference by registering generated paths.** + +[//]: # (embed: ExampleInferPathModeGenerate) + +```go +// In the previous example we've seen [govy.InferPathModeRuntime] in action. +// An alternative for the aforementioned mode which offers better runtime performance +// is [govy.InferPathModeGenerate]. +// +// It comes at a cost of having to run the code generation utility before running your code. +// The utility generates code which uses [govyconfig.SetInferredPath]. +// We'll use this very function in this example to simulate the code generation step. +// The first validator, 'v1', is created with [govy.InferPathModeDisable], +// the second validator, 'v2' is created with [govy.InferPathModeGenerate]. +// As you can see in the output, only the second validator, 'v2' has the inferred path. +func ExampleInferPathModeGenerate() { + govyconfig.SetInferPathIncludeTestFiles(true) + defer govyconfig.SetInferPathIncludeTestFiles(false) + + v1 := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + Rules(rules.EQ("Jerry")), + ). + InferPath(govy.InferPathModeDisable). + WithName("Teacher") + + govyconfig.SetInferredPath(govyconfig.InferredPath{ + Path: jsonpath.New().Name("name"), + File: "pkg/govy/example_test.go", + Line: 2042, + }) + + v2 := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + Rules(rules.EQ("Thomas")), + ). + InferPath(govy.InferPathModeGenerate). + WithName("NotTeacher") + + teacher := Teacher{Name: "Tom"} + if err := v1.Validate(teacher); err != nil { + fmt.Println(err) + } + if err := v2.Validate(teacher); err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed: + // - must be equal to 'Jerry' + // Validation for NotTeacher has failed for the following properties: + // - 'name' with value 'Tom': + // - must be equal to 'Thomas' +} +``` + +## Avoid late inference changes + +Path inference is cached after first validation. Configure the mode before validation, not after an empty path has already been inferred. + + + +**Changing inference mode after first validation does not recompute paths.** + +[//]: # (embed: ExampleValidator_InferPath_changeModeInRuntime) + +```go +// Knowing when to call [govy.Validator.InferPath] is important. +// The path inference runs only once per [govy.PropertyRules] instance, on the first validation. +// Once this happens, the result is cached, even if that result is an empty path. +// +// This example demonstrates that changing the mode after the first validation has no effect. +// The first validation runs with [govy.InferPathModeDisable], which produces an empty path. +// This empty result is then cached. Even after switching to [govy.InferPathModeRuntime], +// the cached empty result persists, so no property path appears in the output. +func ExampleValidator_InferPath_changeModeInRuntime() { + govyconfig.SetInferPathIncludeTestFiles(true) + defer govyconfig.SetInferPathIncludeTestFiles(false) + + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + Rules(rules.EQ("Jerry")), + ). + InferPath(govy.InferPathModeDisable). + WithName("Teacher") + + teacher := Teacher{Name: "Tom"} + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + fmt.Println("---\nAfter setting Runtime infer mode.\n---") + err = v.InferPath(govy.InferPathModeRuntime).Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed: + // - must be equal to 'Jerry' + // --- + // After setting Runtime infer mode. + // --- + // Validation for Teacher has failed: + // - must be equal to 'Jerry' +} +``` diff --git a/.agents/skills/govy/references/predefined-rules.md b/.agents/skills/govy/references/predefined-rules.md new file mode 100644 index 00000000..a545b2c2 --- /dev/null +++ b/.agents/skills/govy/references/predefined-rules.md @@ -0,0 +1,261 @@ +# Predefined Rules + +Examples that show selected predefined rules in use. For the generated catalog of all existing predefined rule constructors, see [Existing Rules](existing-rules.md). + +## Topics + +- [Collection constraints](#collection-constraints) + - [Require values in a slice to be unique.](#require-values-in-a-slice-to-be-unique) +- [Property relationship rules](#property-relationship-rules) + - [Require exactly one value across mutually exclusive properties.](#require-exactly-one-value-across-mutually-exclusive-properties) + - [Require one populated value from a set of properties.](#require-one-populated-value-from-a-set-of-properties) +- [Property comparison rules](#property-comparison-rules) + - [Compare two properties for equality.](#compare-two-properties-for-equality) + - [Compare ordered primitive properties.](#compare-ordered-primitive-properties) + - [Compare custom comparable properties.](#compare-custom-comparable-properties) + +## Collection constraints + +Use collection rules when the validation decision depends on all values, not a single property value. + + + +**Require values in a slice to be unique.** + +[//]: # (embed: ExampleSliceUnique) + +```go +func ExampleSliceUnique() { + v := govy.New( + govy.ForSlice(func(t Teacher) []Student { return t.Students }). + WithName("students"). + Rules(rules.SliceUnique(func(v Student) string { return v.Index }, + "each student must have unique index")), + ) + teacher := Teacher{ + Students: []Student{ + {Index: "foo"}, + {Index: "bar"}, // 2nd element + {Index: "baz"}, + {Index: "bar"}, // 4th element + }, + } + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed for the following properties: + // - 'students' with value '[{"index":"foo"},{"index":"bar"},{"index":"baz"},{"index":"bar"}]': + // - elements are not unique, 2nd and 4th elements collide based on constraints: each student must have unique index +} +``` + +## Property relationship rules + +Use property relationship rules when one field controls whether another field may or must be set. + + + +**Require exactly one value across mutually exclusive properties.** + +[//]: # (embed: ExampleMutuallyExclusive) + +```go +func ExampleMutuallyExclusive() { + v := govy.New( + govy.ForSlice(func(t Teacher) []Student { return t.Students }). + WithName("students"). + RulesForEach(rules.MutuallyExclusive(true, map[string]func(Student) any{ + "index": func(s Student) any { return s.Index }, + "name": func(s Student) any { return s.Name }, + })), + ) + teacher := Teacher{ + Students: []Student{ + {Index: "foo"}, + {Index: "bar", Name: "John"}, + {Name: "Eve"}, + {}, + }, + } + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed for the following properties: + // - 'students[1]' with value '{"index":"bar","name":"John"}': + // - [index, name] properties are mutually exclusive, provide only one of them + // - 'students[3]': + // - one of [index, name] properties must be set, none was provided +} +``` + + + +**Require one populated value from a set of properties.** + +[//]: # (embed: ExampleOneOfProperties) + +```go +func ExampleOneOfProperties() { + v := govy.New( + govy.ForSlice(func(t Teacher) []Student { return t.Students }). + WithName("students"). + RulesForEach(rules.OneOfProperties(map[string]func(Student) any{ + "index": func(s Student) any { return s.Index }, + "name": func(s Student) any { return s.Name }, + })), + ) + teacher := Teacher{ + Students: []Student{ + {Index: "foo"}, + {}, + {Name: "John"}, + {Index: "bar", Name: "Eve"}, + }, + } + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed for the following properties: + // - 'students[1]': + // - one of [index, name] properties must be set, none was provided +} +``` + +## Property comparison rules + +Use comparison rules when multiple properties must preserve ordering or equality. Pick comparable variants for types such as time.Time that define their own ordering contract. + + + +**Compare two properties for equality.** + +[//]: # (embed: ExampleEqualProperties) + +```go +func ExampleEqualProperties() { + v := govy.New( + govy.ForSlice(func(t Teacher) []Student { return t.Students }). + WithName("students"). + RulesForEach(rules.EqualProperties(rules.CompareFunc, map[string]func(Student) any{ + "index": func(s Student) any { return s.Index }, + "indexCopy": func(s Student) any { return s.IndexCopy }, + })), + ) + teacher := Teacher{ + Students: []Student{ + {Index: "foo", IndexCopy: "foo"}, + {Index: "bar"}, + {IndexCopy: "foo"}, + {}, // Both index and indexCopy are empty strings, and thus equal. + }, + } + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed for the following properties: + // - 'students[1]' with value '{"index":"bar"}': + // - all of [index, indexCopy] properties must be equal, but 'index' is not equal to 'indexCopy' + // - 'students[2]' with value '{"indexCopy":"foo"}': + // - all of [index, indexCopy] properties must be equal, but 'index' is not equal to 'indexCopy' +} +``` + + + +**Compare ordered primitive properties.** + +[//]: # (embed: ExampleLTProperties) + +```go +func ExampleLTProperties() { + type IntRange struct { + Min int `json:"min"` + Max int `json:"max"` + } + + v := govy.New( + govy.For(govy.GetSelf[IntRange]()). + Rules( + rules.LTProperties( + "min", func(r IntRange) int { return r.Min }, + "max", func(r IntRange) int { return r.Max }, + ), + ), + ) + + // Valid case: min < max + err := v.Validate(IntRange{Min: 1, Max: 10}) + fmt.Println("Valid:", err == nil) + + // Invalid case: min >= max + err = v.Validate(IntRange{Min: 10, Max: 1}) + if err != nil { + fmt.Println(err) + } + + // Output: + // Valid: true + // Validation has failed: + // - 'min' must be less than 'max' +} +``` + + + +**Compare custom comparable properties.** + +[//]: # (embed: ExampleLTComparableProperties) + +```go +// LTComparableProperties and other *ComparableProperties functions work with types +// that implement [rules.Comparable] interface, such as [time.Time]. +func ExampleLTComparableProperties() { + type TimeRange struct { + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` + } + + v := govy.New( + govy.For(govy.GetSelf[TimeRange]()). + Rules( + rules.LTComparableProperties( + "startTime", func(tr TimeRange) time.Time { return tr.StartTime }, + "endTime", func(tr TimeRange) time.Time { return tr.EndTime }, + ), + ), + ) + + // Valid case: start is before end + err := v.Validate(TimeRange{ + StartTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + EndTime: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), + }) + fmt.Println("Valid:", err == nil) + + // Invalid case: start is after end + err = v.Validate(TimeRange{ + StartTime: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), + EndTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + }) + if err != nil { + fmt.Println(err) + } + + // Output: + // Valid: true + // Validation has failed: + // - 'startTime' must be before 'endTime' +} +``` diff --git a/.agents/skills/govy/references/properties-and-paths.md b/.agents/skills/govy/references/properties-and-paths.md new file mode 100644 index 00000000..a5e409c4 --- /dev/null +++ b/.agents/skills/govy/references/properties-and-paths.md @@ -0,0 +1,550 @@ +# Properties and Paths + +Property getters, names, explicit JSON paths, pointers, transforms, required and optional values, hidden values, and property-level conditions. + +## Topics + +- [Name and path properties](#name-and-path-properties) + - [Name a property rule with one path segment.](#name-a-property-rule-with-one-path-segment) + - [Do not pass dotted paths to WithName.](#do-not-pass-dotted-paths-to-withname) + - [Set multi-segment paths with jsonpath.Path.](#set-multi-segment-paths-with-jsonpathpath) +- [Validate optional and transformed values](#validate-optional-and-transformed-values) + - [Validate the pointed-to value while skipping nil by default.](#validate-the-pointed-to-value-while-skipping-nil-by-default) + - [Transform a property value before applying rules.](#transform-a-property-value-before-applying-rules) +- [Handle empty or sensitive values](#handle-empty-or-sensitive-values) + - [Require a non-empty value before normal rules run.](#require-a-non-empty-value-before-normal-rules-run) + - [Skip rule evaluation for empty optional values.](#skip-rule-evaluation-for-empty-optional-values) + - [Hide sensitive property values in errors.](#hide-sensitive-property-values-in-errors) +- [Use whole-object context](#use-whole-object-context) + - [Validate using the whole current object.](#validate-using-the-whole-current-object) + - [Run property rules only when predicates match.](#run-property-rules-only-when-predicates-match) +- [Control property-level aggregation](#control-property-level-aggregation) + - [Stop evaluating later rules on one property.](#stop-evaluating-later-rules-on-one-property) + +## Name and path properties + +Prefer WithName for a single path segment. Use WithPath when the rule logically targets a nested path or a specific index. + + + +**Name a property rule with one path segment.** + +[//]: # (embed: ExamplePropertyRules_WithName) + +```go +// So far we've been using a very simple [govy.PropertyRules] instance: +// +// validation.For(func(t Teacher) string { return t.Name }). +// Rules(validation.NewRule(func(name string) error { return fmt.Errorf("always fails") })) +// +// The error message returned by this property rule does not tell us +// which property is failing. +// Let's change that by adding an explicit path segment using [govy.PropertyRules.WithName]. +// +// We can also change the [govy.Rule] to be something more real. +// govy comes with a number of predefined [govy.Rule], we'll use +// [rules.EQ] which accepts a single argument, value to compare with. +func ExamplePropertyRules_WithName() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(rules.EQ("Tom")), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jake': + // - must be equal to 'Tom' +} +``` + + + +**Do not pass dotted paths to WithName.** + +[//]: # (embed: ExamplePropertyRules_WithName_wrongUsage) + +```go +// Beware that anything passed into [govy.PropertyRules.WithName] is treated as a single path segment. +// If you pass a dot-separated path-like string into this method, govy renders +// the dots as escaped characters inside one bracket-quoted segment. +// For multi-segment paths, use [govy.PropertyRules.WithPath] instead. +// +// Note: Prior to v0.25.0, [govy.PropertyRules.WithName] treated every string +// as a path, so this usage was valid then. +func ExamplePropertyRules_WithName_wrongUsage() { + v := govy.New( + govy.For(func(t Teacher) string { return t.University.Name }). + WithName("university.name"). // WRONG USAGE! + Rules(rules.EQ("Tom").WithMessage("yikes, looks like you used WithName instead of WithPath!")), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + University: University{ + Name: "Poznan University of Technology", + }, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - '['university.name']' with value 'Poznan University of Technology': + // - yikes, looks like you used WithName instead of WithPath! +} +``` + + + +**Set multi-segment paths with jsonpath.Path.** + +[//]: # (embed: ExamplePropertyRules_WithPath) + +```go +// While [govy.PropertyRules.WithName] is convenient and we recommend using it, +// sometimes you might want to define rules that access nested fields directly. +// That's what [govy.PropertyRules.WithPath] is for. +// +// Unlike [govy.PropertyRules.WithName], [govy.PropertyRules.WithPath] accepts a +// [jsonpath.Path] with one or more segments. +// [govy.PropertyRules.WithName] is just shorthand for `jsonpath.New().Name(...)`. +// +// You can either: +// - pass a string representation of path directly with [jsonpath.Parse] +// - construct the path with a builder API, starting with [jsonpath.New] +func ExamplePropertyRules_WithPath() { + v := govy.New( + govy.For(func(t Teacher) string { return t.University.Name }). + WithPath(jsonpath.Parse("university.name")). + Rules(rules.EQ("Tom")), + govy.For(func(t Teacher) string { return t.Students[0].Index }). + WithPath(jsonpath.New().Name("students").Index(0).Name("index")). + Rules(rules.EQ("2")), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + University: University{ + Name: "Poznan University of Technology", + }, + Students: []Student{ + {Index: "1"}, + }, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'university.name' with value 'Poznan University of Technology': + // - must be equal to 'Tom' + // - 'students[0].index' with value '1': + // - must be equal to '2' +} +``` + +## Validate optional and transformed values + +Use ForPointer for optional pointer fields and Transform when rule input should differ from the stored field type. + + + +**Validate the pointed-to value while skipping nil by default.** + +[//]: # (embed: ExampleForPointer) + +```go +// [govy.For] constructor creates new [govy.PropertyRules] instance. +// It's only argument, [govy.PropertyGetter] is used to extract the property value. +// It works fine for direct values, but falls short when working with pointers. +// Often times we use pointers to indicate that a property is optional, +// or we want to discern between nil and zero values. +// In either case we want our validation rules to work on direct values, +// not the pointer, otherwise we'd have to always check if pointer != nil. +// +// [govy.ForPointer] constructor can be used to solve this problem and allow +// us to work with the underlying value in our rules. +// Under the hood it wraps [govy.PropertyGetter] and safely extracts the underlying value. +// If the value was nil, it will not attempt to evaluate any rules for this property. +// The rationale for that is it doesn't make sense to evaluate any rules for properties +// which are essentially empty. The only rule that makes sense in this context is to +// ensure the property is required. +// We'll learn about a way to achieve that in the next example: [ExamplePropertyRules_Required]. +// +// Let's define a rule for [Teacher.MiddleName] property. +// Not everyone has to have a middle name, that's why we've defined this field +// as a pointer to string, rather than a string itself. +func ExampleForPointer() { + v := govy.New( + govy.ForPointer(func(t Teacher) *string { return t.MiddleName }). + WithName("middleName"). + Rules(rules.StringMaxLength(5)), + ).WithName("Teacher") + + middleName := "Thaddeus" + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + MiddleName: &middleName, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'middleName' with value 'Thaddeus': + // - length must be less than or equal to 5 +} +``` + + + +**Transform a property value before applying rules.** + +[//]: # (embed: ExampleTransform) + +```go +// [govy.Transform] constructor can be used to transform the property's value +// before it's passed to the rules' evaluation. +// It's useful when you want to use rules that operate on a different type than the property's. +// +// Along with the standard [govy.PropertyGetter] it accepts a [govy.Transformer] function +// which takes the property value and returns the transformed value along with an error. +// If the error is not nil, the validation will fail with the error message returned by [govy.Transformer] error. +// +// In this example we'll use [time.ParseDuration] to transform the string value of [Clock.Duration] to [time.Duration]. +// The first value we'll validate will force [govy.Transformer] to return an error, +// the second will succeed transformation, but it will fail the validation for [rules.DurationPrecision]. +// +// Notice how the [govy.Transformer] shape adheres to a lot of standard library conversion/parsing functions. +func ExampleTransform() { + type Clock struct { + Duration string `json:"duration"` + } + v := govy.New( + govy.Transform(func(c Clock) string { return c.Duration }, time.ParseDuration). + WithName("duration"). + Rules(rules.DurationPrecision(time.Minute)), + ).WithName("MyClock") + + err := v.Validate(Clock{Duration: "bad duration!"}) + if err != nil { + fmt.Println(err) + } + + err = v.Validate(Clock{Duration: (256 * time.Second).String()}) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for MyClock has failed for the following properties: + // - 'duration' with value 'bad duration!': + // - time: invalid duration "bad duration!" + // Validation for MyClock has failed for the following properties: + // - 'duration' with value '4m16s': + // - duration must be defined with 1m0s precision +} +``` + +## Handle empty or sensitive values + +Use Required to fail fast on empty values, OmitEmpty to skip optional direct values, and HideValue when errors must not expose the original input. + + + +**Require a non-empty value before normal rules run.** + +[//]: # (embed: ExamplePropertyRules_Required) + +```go +// By default, when [govy.PropertyRules] is constructed using [govy.ForPointer] +// it will skip validation of the property if the pointer is nil. +// To enforce a value is set for pointer use [govy.PropertyRules.Required]. +// +// You may ask yourself why not just use [rules.Required] rule instead? +// If we were to do that, we'd be forced to operate on pointer in all of our rules. +// Other than checking if the pointer is nil, there aren't any rules which would +// benefit from working on the pointer instead of the underlying value. +// +// If you want to also make sure the underlying value is filled, +// i.e. it's not a zero value, you can also use [rules.Required] rule +// on top of [govy.PropertyRules.Required]. +// +// [govy.PropertyRules.Required] when used with [govy.For] constructor, will ensure +// the property does not contain a zero value. +// +// Note: [govy.PropertyRules.Required] is introducing a short circuit. +// If the assertion fails, validation will stop and return [rules.ErrorCodeRequired]. +// None of the rules you've defined would be evaluated. +// +// Note: Placement of [govy.PropertyRules.Required] does not matter, +// it's not evaluated in a sequential loop, unlike standard [govy.Rule]. +// However, we recommend you always place it below [govy.PropertyRules.WithName] +// to make your rules more readable. +func ExamplePropertyRules_Required() { + alwaysFailingRule := govy.NewRule(func(string) error { + return fmt.Errorf("always fails") + }) + + v := govy.New( + govy.ForPointer(func(t Teacher) *string { return t.MiddleName }). + WithName("middleName"). + Required(). + Rules(alwaysFailingRule), + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Required(). + Rules(alwaysFailingRule), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "", + Age: 51 * year, + MiddleName: nil, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'middleName': + // - property is required but was empty + // - 'name': + // - property is required but was empty +} +``` + + + +**Skip rule evaluation for empty optional values.** + +[//]: # (embed: ExamplePropertyRules_OmitEmpty) + +```go +// While [govy.ForPointer] will by default omit validation for nil pointers, +// it might be useful to have a similar behavior for optional properties +// which are direct values. +// [govy.PropertyRules.OmitEmpty] will do the trick. +// +// Note: [govy.PropertyRules.OmitEmpty] will have no effect on pointers handled +// by [govy.ForPointer], as they already behave in the same way. +func ExamplePropertyRules_OmitEmpty() { + alwaysFailingRule := govy.NewRule(func(string) error { + return fmt.Errorf("always fails") + }) + + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + OmitEmpty(). + Rules(alwaysFailingRule), + govy.ForPointer(func(t Teacher) *string { return t.MiddleName }). + WithName("middleName"). + Rules(alwaysFailingRule), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "", + Age: 51 * year, + MiddleName: nil, + } + + err := v.Validate(teacher) + if err == nil { + fmt.Println("no error! we skipped 'name' validation and 'middleName' is implicitly skipped") + } + + // Output: + // no error! we skipped 'name' validation and 'middleName' is implicitly skipped +} +``` + + + +**Hide sensitive property values in errors.** + +[//]: # (embed: ExamplePropertyRules_HideValue) + +```go +// Sometimes you want to hide the value of the property in the error message. +// It can contain sensitive information, like a secret access key. +// You can use [govy.PropertyRules.HideValue] to achieve that. +// +// You can see that the error message now contains "[hidden]" instead of the actual value, +// and the property value is not included in the property bullet point (- 'name'). +func ExamplePropertyRules_HideValue() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + HideValue(). + Rules(govy.NewRule(func(name string) error { return fmt.Errorf("that Jake is secret") })), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name': + // - that [hidden] is secret +} +``` + +## Use whole-object context + +Use GetSelf when the rule compares multiple fields in the same object. Use property-level When to keep branch-specific rules isolated. + + + +**Validate using the whole current object.** + +[//]: # (embed: ExampleGetSelf) + +```go +// If you want to access the value of the entity you're writing the [govy.Validator] for, +// you can use [govy.GetSelf] function which is a convenience [govy.PropertyGetter] that returns self. +// Note that we don't call [govy.PropertyRules.WithName] here, +// as we're comparing two properties in our top level, [Teacher] scope. +// +// You can provide your own rules using [govy.NewRule] constructor. +// It returns new [govy.Rule] instance which wraps your validation function. +func ExampleGetSelf() { + customRule := govy.NewRule(func(v Teacher) error { + return fmt.Errorf("now I have access to the whole teacher") + }) + + v := govy.New( + govy.For(govy.GetSelf[Teacher]()). + Rules(customRule), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed: + // - now I have access to the whole teacher +} +``` + + + +**Run property rules only when predicates match.** + +[//]: # (embed: ExamplePropertyRules_When) + +```go +// To only run property validation on condition, use [govy.PropertyRules.When]. +// Predicates set through [govy.PropertyRules.When] are evaluated in the order they are provided. +// If any predicate is not met, validation rules are not evaluated for the whole [govy.PropertyRules]. +// +// It's recommended to define [govy.PropertyRules.When] before [govy.PropertyRules.Rules] declaration. +func ExamplePropertyRules_When() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + When(func(t Teacher) bool { return t.Name == "Jerry" }). + Rules(rules.NEQ("Jerry")), + ).WithName("Teacher") + + for _, name := range []string{"Tom", "Jerry", "Mickey"} { + teacher := Teacher{Name: name} + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jerry': + // - must not be equal to 'Jerry' +} +``` + +## Control property-level aggregation + +Property cascade overrides validator cascade for that property and is useful when later rules add noise after an earlier failure. + + + +**Stop evaluating later rules on one property.** + +[//]: # (embed: ExamplePropertyRules_Cascade) + +```go +// To customize how [govy.Rule] are evaluated use [govy.PropertyRules.Cascade]. +// Use [govy.CascadeModeStop] to stop validation after the first error. +// If you wish to revert to the default behavior, use [govy.CascadeModeContinue]. +// +// Note: the cascade mode change only applies to the given [govy.PropertyRules] instance +// and not the parent [govy.Validator] or neighboring [govy.PropertyRules]. +// It does however override the [govy.CascadeMode] set for [govy.Validator]. +func ExamplePropertyRules_Cascade() { + alwaysFailingRule := govy.NewRule(func(string) error { + return fmt.Errorf("always fails") + }) + + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Cascade(govy.CascadeModeStop). + Rules(rules.NEQ("Jerry")). + Rules(alwaysFailingRule), + ).WithName("Teacher") + + for _, name := range []string{"Tom", "Jerry"} { + teacher := Teacher{Name: name} + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Tom': + // - always fails + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jerry': + // - must not be equal to 'Jerry' +} +``` diff --git a/.agents/skills/govy/references/rules-and-messages.md b/.agents/skills/govy/references/rules-and-messages.md new file mode 100644 index 00000000..0b994951 --- /dev/null +++ b/.agents/skills/govy/references/rules-and-messages.md @@ -0,0 +1,758 @@ +# Creating and Modifying Rules + +Creating new rules and modifying rules with details, examples, error codes, messages, message templates, template functions, pointer conversion, and rule sets. + +## Topics + +- [Create custom rules](#create-custom-rules) + - [Create a custom rule from a validation function.](#create-a-custom-rule-from-a-validation-function) +- [Add rule context](#add-rule-context) + - [Attach static details to a rule error.](#attach-static-details-to-a-rule-error) + - [Attach formatted details when the context is computed.](#attach-formatted-details-when-the-context-is-computed) + - [Attach valid input examples for complex rules.](#attach-valid-input-examples-for-complex-rules) + - [Attach stable error codes for tests and integrations.](#attach-stable-error-codes-for-tests-and-integrations) +- [Customize rule messages](#customize-rule-messages) + - [Override a rule message with a fixed string.](#override-a-rule-message-with-a-fixed-string) + - [Build a rule message dynamically from the failed value.](#build-a-rule-message-dynamically-from-the-failed-value) +- [Use message templates](#use-message-templates) + - [Attach a template from a string.](#attach-a-template-from-a-string) + - [Attach a pre-parsed template.](#attach-a-pre-parsed-template) +- [Add template helper functions](#add-template-helper-functions) + - [Register the builtin helper functions on a template.](#register-the-builtin-helper-functions-on-a-template) + - [Format example slices the same way default messages do.](#format-example-slices-the-same-way-default-messages-do) + - [Join slice values in custom template output.](#join-slice-values-in-custom-template-output) + - [Indent multiline template output.](#indent-multiline-template-output) +- [Describe rules for generated plans](#describe-rules-for-generated-plans) + - [Attach a plan-only rule description.](#attach-a-plan-only-rule-description) +- [Reuse rule sets](#reuse-rule-sets) + - [Convert a rule so it can validate pointer values.](#convert-a-rule-so-it-can-validate-pointer-values) + - [Group multiple rules into a reusable set.](#group-multiple-rules-into-a-reusable-set) + - [Convert a whole rule set for pointer validation.](#convert-a-whole-rule-set-for-pointer-validation) + - [Stop evaluating later rules in a rule set after failure.](#stop-evaluating-later-rules-in-a-rule-set-after-failure) + +## Create custom rules + +Use custom rules for validation that is not covered by pkg/rules. Keep the rule function focused on the validation decision and attach context through rule metadata methods. + + + +**Create a custom rule from a validation function.** + +[//]: # (embed: ExampleRule) + +```go +// Govy comes with a set of predefined rules, +// which you can use out of the box by importing [rules] package. +// +// However, you can also create your own rules by using [govy.NewRule] constructor. +// It accepts a simple validation function which takes in a value +// and returns an error if the validation failed. +// +// Note: the [govy.Rule] struct has all its fields private, +// so you can only create and modify them using exported constructor and methods. +func ExampleRule() { + myRule := govy.NewRule(func(name string) error { + if name != "Tom" { + return fmt.Errorf("Teacher can be only Tom") + } + return nil + }) + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(myRule), + ) + + teacher := Teacher{Name: "Jake"} + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed for the following properties: + // - 'name' with value 'Jake': + // - Teacher can be only Tom +} +``` + +## Add rule context + +Use details, examples, and error codes to make rule failures useful to both humans and tests. Details extend the message, examples document valid inputs, and error codes provide a stable assertion surface. + + + +**Attach static details to a rule error.** + +[//]: # (embed: ExampleRule_WithDetails) + +```go +// You can use [govy.Rule.WithDetails] to add additional details to the error message. +// This allows you to extend existing rules by adding your use case context. +// Let's give a regex validation some more clarity. +func ExampleRule_WithDetails() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(rules.StringMatchRegexp(regexp.MustCompile("^(Tom|Jerry)$")). + WithDetails("Teacher can be either Tom or Jerry :)")), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jake': + // - string must match regular expression: '^(Tom|Jerry)$'; Teacher can be either Tom or Jerry :) +} +``` + + + +**Attach formatted details when the context is computed.** + +[//]: # (embed: ExampleRule_WithDetailsf) + +```go +// You can use [govy.Rule.WithDetailsf] to add formatted details to the returned [govy.RuleError] error message. +func ExampleRule_WithDetailsf() { + minLen := 3 + maxLen := 10 + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(rules.StringLength(minLen, maxLen). + WithDetailsf("Teacher name must be between %d and %d characters", minLen, maxLen)), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jo", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jo': + // - length must be between 3 and 10; Teacher name must be between 3 and 10 characters +} +``` + + + +**Attach valid input examples for complex rules.** + +[//]: # (embed: ExampleRule_WithExamples) + +```go +// You can use [govy.Rule.WithExamples] to add examples of valid inputs +// which pass the [govy.Rule]. +// This can be useful for more complex rules, especially regex based, where +// it might not be immediately obvious how a valid value should look like. +// +// Note: examples are added between the error message and details +// (configured with [govy.Rule.WithDetails]). +func ExampleRule_WithExamples() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(rules.StringMatchRegexp(regexp.MustCompile("^(Tom|Jerry)$")). + WithDetails("Teacher can be either Tom or Jerry :)"). + WithExamples("Tom", "Jerry")), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jake': + // - string must match regular expression: '^(Tom|Jerry)$' (e.g. 'Tom', 'Jerry'); Teacher can be either Tom or Jerry :) +} +``` + + + +**Attach stable error codes for tests and integrations.** + +[//]: # (embed: ExampleRule_WithErrorCode) + +```go +// When testing, it can be tedious to always rely on error messages as these can change over time. +// Enter [govy.ErrorCode], which is a simple string type alias used to ease testing, +// but also potentially allow third parties to integrate with your validation results. +// Use [govy.Rule.WithErrorCode] to associate [govy.ErrorCode] with a [govy.Rule]. +// Notice that our modified version of [rules.StringMatchRegexp] will now return a new [govy.ErrorCode]. +// Predefined rules have [govy.ErrorCode] already associated with them. +// To view the list of predefined [govy.ErrorCode] checkout error_codes.go file. +func ExampleRule_WithErrorCode() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(rules.StringMatchRegexp(regexp.MustCompile("^(Tom|Jerry)$")). + WithDetails("Teacher can be either Tom or Jerry :)"). + WithErrorCode("custom_code")), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + propertyErrors := err.(*govy.ValidatorError).Errors + ruleErrors := propertyErrors[0].Errors + fmt.Println(ruleErrors[0].Code) + } + + // Output: + // custom_code +} +``` + +## Customize rule messages + +Use WithMessage for fixed replacements and WithMessagef when the failed value or details should shape the final message. These methods replace the base message while preserving details and examples. + + + +**Override a rule message with a fixed string.** + +[//]: # (embed: ExampleRule_WithMessage) + +```go +// If you want to override the default error message, you can use [govy.Rule.WithMessage]. +func ExampleRule_WithMessage() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(rules.StringMatchRegexp(regexp.MustCompile("^(Tom|Jerry)$")). + WithDetails("Teacher can be either Tom or Jerry :)"). + WithMessage("unsupported name")), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jake': + // - unsupported name; Teacher can be either Tom or Jerry :) +} +``` + + + +**Build a rule message dynamically from the failed value.** + +[//]: # (embed: ExampleRule_WithMessagef) + +```go +// You can use [govy.Rule.WithMessagef] to override the default error message using printf-like formatting. +func ExampleRule_WithMessagef() { + allowedNames := []string{"Tom", "Jerry"} + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(rules.StringMatchRegexp(regexp.MustCompile("^(Tom|Jerry)$")). + WithMessagef("name must be one of: %v", allowedNames)), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jake': + // - name must be one of: [Tom Jerry] +} +``` + +## Use message templates + +Use message templates when message formatting needs access to rule data such as details, examples, or property values. Start with a string template for simple cases and parse templates yourself when you need reuse or validation before attaching them. + + + +**Attach a template from a string.** + +[//]: # (embed: ExampleRule_WithMessageTemplateString) + +```go +// If you want to have more control over the resulting error message, but [govy.Rule.WithMessage] +// is not enough, you can utilize a template string which is parsed by [govy.Rule] into +// [template.Template] to construct a custom error message. +// +// Each builtin rule supports different variables. +// For instance, [rules.StringLength] supports 'MinLength' and 'MaxLength' variables. +// Refer to the rule's documentation to see which variables are supported. +// +// Note: Builtin functions provided by [govy.AddTemplateFunctions], like 'formatExamples', +// are automatically added to the parsed [template.Template]. +func ExampleRule_WithMessageTemplateString() { + tplString := `Teacher's name must be between {{ .MinLength }} and {{ .MaxLength }} characters {{ formatExamples .Examples }}.` + + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(rules.StringLength(5, 10). + WithExamples("Joanna", "Angeline"). + WithMessageTemplateString(tplString)), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Eve", + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Eve': + // - Teacher's name must be between 5 and 10 characters (e.g. 'Joanna', 'Angeline'). +} +``` + + + +**Attach a pre-parsed template.** + +[//]: # (embed: ExampleRule_WithMessageTemplate) + +```go +// If you want to have more control over the [template.Template] used for error message creation, +// for instance, add custom functions, use [govy.Rule.WithMessageTemplate]. +// +// In the example below, we're defining a custom template function 'join' which calls [strings.Join] +// under the hood to join a slice of strings with a comma. +// +// Note: 'Examples' field is a plain slice of strings, If you wish to format it the same way +// as the default message does, use 'formatExamples' function provided by [govy.AddTemplateFunctions]. +func ExampleRule_WithMessageTemplate() { + tplString := `Teacher's name '{{ .PropertyValue }}' is not supported. {{ .Details }} (e.g. {{ join .Examples ", " }}).` + tpl := template.New("").Funcs(template.FuncMap{"join": strings.Join}) + tpl = template.Must(tpl.Parse(tplString)) + + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(rules.StringLength(5, 10). + WithDetails("Teacher's name must be between 5 and 10 characters"). + WithExamples("Joanna", "Angeline"). + WithMessageTemplate(tpl)), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Eve", + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Eve': + // - Teacher's name 'Eve' is not supported. Teacher's name must be between 5 and 10 characters (e.g. Joanna, Angeline). +} +``` + +## Add template helper functions + +Template helpers are registered through AddTemplateFunctions. Use the built-ins for common formatting instead of formatting slices or multiline text by hand inside every template. + + + +**Register the builtin helper functions on a template.** + +[//]: # (embed: ExampleAddTemplateFunctions) + +```go +// Under the hood builtin rules' message templates utilize a set of custom template functions. +// If you want to use them in your custom templates, you can add them to your [template.Template] +// instance by calling [govy.AddTemplateFunctions]. +// +// An example of such function is 'formatExamples' which takes in a slice of strings +// and returns a formatted string. +// +// Note: Builtin functions are automatically added to the parsed [template.Template] if you're using +// [govy.Rule.WithMessageTemplateString]. +// +// Note: [govy.AddTemplateFunctions] calls [template.Template.Funcs], which will not add the functions +// to your template If it was already parsed. +func ExampleAddTemplateFunctions() { + tplString := `Teacher's name '{{ .PropertyValue }}' is not supported {{ formatExamples .Examples }}.` + tpl := template.New("") + tpl = govy.AddTemplateFunctions(tpl) + tpl = template.Must(tpl.Parse(tplString)) + + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(rules.StringLength(5, 10). + WithExamples("Joanna", "Angeline"). + WithMessageTemplate(tpl)), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Eve", + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Eve': + // - Teacher's name 'Eve' is not supported (e.g. 'Joanna', 'Angeline'). +} +``` + + + +**Format example slices the same way default messages do.** + +[//]: # (embed: ExampleAddTemplateFunctions_formatExamples) + +```go +func ExampleAddTemplateFunctions_formatExamples() { + tplString := "{{ formatExamples .Examples }}" + tpl := template.New("") + tpl = govy.AddTemplateFunctions(tpl) + tpl = template.Must(tpl.Parse(tplString)) + + err := tpl.Execute( + os.Stdout, + map[string]any{"Examples": []string{"Joanna", "Angeline"}}, + ) + if err != nil { + fmt.Println(err) + } + + // Output: + // (e.g. 'Joanna', 'Angeline') +} +``` + + + +**Join slice values in custom template output.** + +[//]: # (embed: ExampleAddTemplateFunctions_joinSlice) + +```go +func ExampleAddTemplateFunctions_joinSlice() { + tplString := `{{ joinSlice .Slice "'" }}` + tpl := template.New("") + tpl = govy.AddTemplateFunctions(tpl) + tpl = template.Must(tpl.Parse(tplString)) + + err := tpl.Execute( + os.Stdout, + map[string]any{"Slice": []string{"Joanna", "Angeline"}}, + ) + if err != nil { + fmt.Println(err) + } + + // Output: + // 'Joanna', 'Angeline' +} +``` + + + +**Indent multiline template output.** + +[//]: # (embed: ExampleAddTemplateFunctions_indent) + +```go +func ExampleAddTemplateFunctions_indent() { + tplString := "{{ indent 2 .Details }}" + tpl := template.New("") + tpl = govy.AddTemplateFunctions(tpl) + tpl = template.Must(tpl.Parse(tplString)) + + err := tpl.Execute( + os.Stdout, + map[string]any{"Details": "foo\nbar"}, + ) + if err != nil { + fmt.Println(err) + } + + // Output: + // foo + // bar +} +``` + +## Describe rules for generated plans + +Use descriptions when validation plans are part of the API contract. Descriptions are plan metadata; they do not change runtime validation behavior. + + + +**Attach a plan-only rule description.** + +[//]: # (embed: ExampleRule_WithDescription) + +```go +// [govy.Rule] error might be static, i.e. a single [govy.Rule] always returns +// the same exact error message, but they don't have to. +// For instance, consider a rule which parses a URL using [net/url] package. +// +// This makes it very hard to infer error message for [govy.RulePlan], if not +// impossible, since the exact error might only be known during runtime. +// +// To solve this problem, you can use [govy.Rule.WithDescription] to provide a +// verbose and informative rule description. +// It will be only included in the [govy.RulePlan] and otherwise not displayed in +// the default [govy.RuleError.Error]. +// However, it is available in the structured [govy.RuleError]. +func ExampleRule_WithDescription() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(rules.StringMatchRegexp(regexp.MustCompile("^(Tom|Jerry)$")). + WithDetails("Teacher can be either Tom or Jerry :)"). + WithMessage("unsupported name")), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jake", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jake': + // - unsupported name; Teacher can be either Tom or Jerry :) +} +``` + +## Reuse rule sets + +Use rule sets to package related rules and reuse them across properties. Convert rules or rule sets to pointer variants when validating pointed-to values. + + + +**Convert a rule so it can validate pointer values.** + +[//]: # (embed: ExampleRuleToPointer) + +```go +// The builtin rules, and most likely your custom rules as well, all operate on non-pointer values. +// This means you cannot use them on pointers to the same type. +// +// If for whatever reason you don't want to use [govy.ForPointer] constructor, +// you can use [govy.RuleToPointer] constructor and convert any [govy.Rule] to work on pointers. +// +// Note: [govy.RuleToPointer] will skip validation for nil pointers. +// If you want to enforce the value to be non-nil, you can use [rules.Required]. +// This behavior is consistent with [govy.ForPointer] constructor, which will skip the validation +// unless you add [govy.PropertyRules.Required] to enforce the value to be a non-nil pointer. +func ExampleRuleToPointer() { + type Pointer struct { + Pointer *string `json:"pointer"` + } + validator := govy.New( + govy.For(func(p Pointer) *string { return p.Pointer }). + WithName("pointer"). + Rules(govy.RuleToPointer(rules.EQ("foo"))), + ) + + pointer := Pointer{Pointer: ptr("bar")} + + err := validator.Validate(pointer) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed for the following properties: + // - 'pointer' with value 'bar': + // - must be equal to 'foo' +} +``` + + + +**Group multiple rules into a reusable set.** + +[//]: # (embed: ExampleRuleSet) + +```go +// Sometimes it's useful to aggregate multiple [govy.Rule] into a single, composite rule. +// To do that we'll use [govy.RuleSet] and [govy.NewRuleSet] constructor. +// RuleSet is a simple container for multiple [govy.Rule]. +// During validation it is unpacked and each [govy.RuleError] is reported separately. +// +// Note that govy uses similar syntax to wrapped errors in Go; +// a ':' delimiter is used to chain error codes together. +func ExampleRuleSet() { + teacherNameRule := govy.NewRuleSet( + rules.StringLength(1, 5), + rules.StringMatchRegexp(regexp.MustCompile("^(Tom|Jerry)$")). + WithDetails("Teacher can be either Tom or Jerry :)"), + ). + WithErrorCode("teacher_name") + + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(teacherNameRule), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jonathan", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + propertyErrors := err.(*govy.ValidatorError).Errors + ruleErrors := propertyErrors[0].Errors + fmt.Printf("Error codes: %s, %s\n\n", ruleErrors[0].Code, ruleErrors[1].Code) + fmt.Println(err) + } + + // Output: + // Error codes: teacher_name:string_length, teacher_name:string_match_regexp + // + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jonathan': + // - length must be between 1 and 5 + // - string must match regular expression: '^(Tom|Jerry)$'; Teacher can be either Tom or Jerry :) +} +``` + + + +**Convert a whole rule set for pointer validation.** + +[//]: # (embed: ExampleRuleSetToPointer) + +```go +// Similar to [govy.RuleToPointer], you can use [govy.RuleSetToPointer] to convert +// [govy.RuleSet] to work with pointers. +// +// See [ExampleRuleToPointer] for more details. +func ExampleRuleSetToPointer() { + type Pointer struct { + Pointer *string `json:"pointer"` + } + ruleSet := govy.NewRuleSet( + rules.StringStartsWith("f"), + rules.StringEndsWith("o"), + ) + validator := govy.New( + govy.For(func(p Pointer) *string { return p.Pointer }). + WithName("pointer"). + Rules(govy.RuleSetToPointer(ruleSet)), + ) + + pointer := Pointer{Pointer: ptr("bar")} + + err := validator.Validate(pointer) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation has failed for the following properties: + // - 'pointer' with value 'bar': + // - string must start with 'f' prefix + // - string must end with 'o' suffix +} +``` + + + +**Stop evaluating later rules in a rule set after failure.** + +[//]: # (embed: ExampleRuleSet_Cascade) + +```go +// If you wish to control how rules aggregated by [govy.RuleSet] evaluate +// you can use [govy.RuleSet.Cascade] to set a [govy.CascadeMode]. +// +// Similar to how the cascade mode works when evaluating [govy.PropertyRules], +// the [govy.CascadeModeStop] will stop validation after the first encountered error. +// +// In the example below we can see that although both rules should fail, +// only the first one (order of definitions matters here!) returns an error. +func ExampleRuleSet_Cascade() { + teacherNameRule := govy.NewRuleSet( + rules.StringLength(1, 5), + rules.StringMatchRegexp(regexp.MustCompile("^(Tom|Jerry)$")), + ). + Cascade(govy.CascadeModeStop) + + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(teacherNameRule), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jonathan", + } + + err := v.Validate(teacher) + if err != nil { + fmt.Println(err) + } + + // Output: + // Validation for Teacher has failed for the following properties: + // - 'name' with value 'Jonathan': + // - length must be between 1 and 5 +} +``` diff --git a/.agents/skills/govy/references/testing.md b/.agents/skills/govy/references/testing.md new file mode 100644 index 00000000..799209a7 --- /dev/null +++ b/.agents/skills/govy/references/testing.md @@ -0,0 +1,134 @@ +# Testing + +Core govy error helpers and structured error flows useful when writing tests without the govytest package. + +## Topics + +- [Inspect validation errors](#inspect-validation-errors) + - [Check whether any nested rule error carries an error code.](#check-whether-any-nested-rule-error-carries-an-error-code) + - [Match validator errors returned from slice validation.](#match-validator-errors-returned-from-slice-validation) + +## Inspect validation errors + +Use these helpers when tests need to match structured govy errors without depending on entire formatted error strings. + + + +**Check whether any nested rule error carries an error code.** + +[//]: # (embed: ExampleHasErrorCode) + +```go +// To inspect if an error contains a given [govy.ErrorCode], use [govy.HasErrorCode] function. +// This function will also return true if the expected [govy.ErrorCode] +// is part of a chain of wrapped error codes. +// In this example we're dealing with two error code chains: +// - 'teacher_name:string_length' +// - 'teacher_name:string_match_regexp' +func ExampleHasErrorCode() { + teacherNameRule := govy.NewRuleSet( + rules.StringLength(1, 5), + rules.StringMatchRegexp(regexp.MustCompile("^(Tom|Jerry)$")), + ). + WithErrorCode("teacher_name") + + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(teacherNameRule), + ).WithName("Teacher") + + teacher := Teacher{ + Name: "Jonathan", + Age: 51 * year, + } + + err := v.Validate(teacher) + if err != nil { + for _, code := range []govy.ErrorCode{ + "teacher_name", + "string_length", + "string_match_regexp", + } { + if govy.HasErrorCode(err, code) { + fmt.Println("Has error code:", code) + } + } + } + + // Output: + // Has error code: teacher_name + // Has error code: string_length + // Has error code: string_match_regexp +} +``` + + + +**Match validator errors returned from slice validation.** + +[//]: # (embed: ExampleValidatorErrors) + +```go +// [govy.Validator.ValidateSlice] outputs [govy.ValidatorErrors] which is a slice of [govy.ValidatorError]. +// Each [govy.ValidatorError] has an additional property set: SliceIndex, which is a 0-based slice element index. +func ExampleValidatorErrors() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + Rules(govy.NewRule(func(name string) error { + if name == "John" || name == "Jake" { + return fmt.Errorf("fails for John and Jake") + } + return nil + })), + ).WithName("Teacher") + + err := v.ValidateSlice([]Teacher{ + {Name: "John"}, + {Name: "George"}, + {Name: "Jake"}, + }) + if err != nil { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err = enc.Encode(err); err != nil { + fmt.Printf("error encoding: %v\n", err) + } + } + + // Output: + // [ + // { + // "errors": [ + // { + // "propertyPath": "name", + // "propertyValue": "John", + // "errors": [ + // { + // "error": "fails for John and Jake" + // } + // ] + // } + // ], + // "name": "Teacher", + // "sliceIndex": 0 + // }, + // { + // "errors": [ + // { + // "propertyPath": "name", + // "propertyValue": "Jake", + // "errors": [ + // { + // "error": "fails for John and Jake" + // } + // ] + // } + // ], + // "name": "Teacher", + // "sliceIndex": 2 + // } + // ] +} +``` diff --git a/.agents/skills/govy/references/validation-plan.md b/.agents/skills/govy/references/validation-plan.md new file mode 100644 index 00000000..07c8643b --- /dev/null +++ b/.agents/skills/govy/references/validation-plan.md @@ -0,0 +1,132 @@ +# Validation Plan + +Validation plan generation and strict validation of plan metadata. + +## Topics + +- [Generate and enforce validation plans](#generate-and-enforce-validation-plans) + - [Generate a plan from a validator.](#generate-a-plan-from-a-validator) + - [Validate plan metadata with strict plan options.](#validate-plan-metadata-with-strict-plan-options) + +## Generate and enforce validation plans + +Use validation plans when validation rules also need to describe an API contract. Add examples, descriptions, and predicate descriptions before generating the plan. + + + +**Generate a plan from a validator.** + +[//]: # (embed: ExamplePlan) + +```go +// When documenting an API it's often a struggle to keep consistency +// between the code and documentation we write for it. +// What If your code could be self-descriptive? +// Specifically, what If we could generate documentation out of our validation rules? +// We can achieve that by using [govy.Plan] function! +// +// There are multiple ways to improve the generated documentation: +// - Use [govy.PropertyRules.WithExamples] to provide a list of example values for the property. +// - Use [govy.Rule.WithDescription] to provide a plan-only description for your rule. +// For builtin rules, the description is already provided. +// - Use [govy.WhenDescription] to provide a plan-only description for your when conditions. +func ExamplePlan() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + WithExamples("Jake", "John"). + When( + func(t Teacher) bool { return t.Name == "Jerry" }, + govy.WhenDescription("name is Jerry"), + ). + Rules( + rules.NEQ("Jerry"). + WithDetails("Jerry is just a name!"), + govy.NewRule(func(v string) error { + return fmt.Errorf("some custom error") + }). + WithDescription("this is a custom error!"), + ), + ).WithName("Teacher") + + properties, err := govy.Plan(v) + if err != nil { + panic(err) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(properties) + + // Output: + // { + // "name": "Teacher", + // "properties": [ + // { + // "path": "$.name", + // "typeInfo": { + // "name": "string", + // "kind": "string" + // }, + // "examples": [ + // "Jake", + // "John" + // ], + // "rules": [ + // { + // "description": "must not be equal to 'Jerry'", + // "details": "Jerry is just a name!", + // "errorCode": "not_equal_to", + // "conditions": [ + // "name is Jerry" + // ] + // }, + // { + // "description": "this is a custom error!", + // "conditions": [ + // "name is Jerry" + // ] + // } + // ] + // } + // ] + // } +} +``` + + + +**Validate plan metadata with strict plan options.** + +[//]: # (embed: ExamplePlan_validation) + +```go +// You can enforce certain rules upon [govy.Plan]. +// For instance, If you'd want to make sure every [govy.Predicate] +// has a description attached to it, provide [govy.Plan] with [govy.PlanRequirePredicateDescription] option. +// +// If you want to follow our best recommendations, use [govy.PlanStrictMode]. +func ExamplePlan_validation() { + v := govy.New( + govy.For(func(t Teacher) string { return t.Name }). + WithName("name"). + WithExamples("Jake", "John"). + When(func(t Teacher) bool { return t.Name == "Jerry" }). + Rules( + rules.NEQ("Jerry"). + WithDetails("Jerry is just a name!"), + govy.NewRule(func(v string) error { + return fmt.Errorf("some custom error") + }). + WithDescription("this is a custom error!"), + ), + ). + When(func(t Teacher) bool { return t.Age > 18 }). + WithName("Teacher") + + _, err := govy.Plan(v, govy.PlanStrictMode()) + fmt.Println(err) + + // Output: + // predicates without description found at: validator level, $.name +} +``` diff --git a/Makefile b/Makefile index 74a44473..e138b935 100644 --- a/Makefile +++ b/Makefile @@ -102,10 +102,10 @@ generate/code: $(call _print_step,Generating Go code) go generate ./... -## Generate README.md file embedded examples. +## Generate Markdown embedded examples. generate/readme: - $(call _print_step,Generating README.md embedded examples) - $(SCRIPTS_DIR)/embed-example-in-readme.bash README.md + $(call _print_step,Generating Markdown embedded examples) + $(SCRIPTS_DIR)/embed-examples-in-markdown.bash README.md .agents/skills/govy .PHONY: format format/go ## Format files. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 7c1d10ab..6fe26742 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -61,8 +61,8 @@ shortcomings. Some parts of the codebase are automatically generated. We use the following tools to do that: -- [embed-example-in-readme.bash](../scripts/embed-example-in-readme.bash) - for embedding tested examples in [README.md](../README.md). +- [embed-examples-in-markdown.bash](../scripts/embed-examples-in-markdown.bash) + for embedding tested examples in Markdown documents. ## Validation diff --git a/go.work.sum b/go.work.sum index c0b9c192..9c7bbf8a 100644 --- a/go.work.sum +++ b/go.work.sum @@ -49,6 +49,7 @@ golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJ golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0= golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= @@ -61,6 +62,7 @@ golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= diff --git a/internal/cmd/docextractor/main.go b/internal/cmd/docextractor/main.go index 7c5d66fe..c6c2b2eb 100644 --- a/internal/cmd/docextractor/main.go +++ b/internal/cmd/docextractor/main.go @@ -6,10 +6,13 @@ import ( "go/ast" "go/format" "go/parser" + "go/printer" "go/token" + "io/fs" "log/slog" "os" "path/filepath" + "slices" "strconv" "strings" @@ -29,6 +32,16 @@ func main() { fmt.Println("Running docextractor...") root := internal.FindModuleRoot() + if len(os.Args) > 1 { + switch os.Args[1] { + case "embed": + embedExamples(root, os.Args[2:]) + return + default: + logFatal(nil, "Unknown docextractor command %q", os.Args[1]) + } + } + docs := findTemplateFunctionsDocs(root) path := filepath.Join(root, "pkg", "govy", "message_templates.go") @@ -185,10 +198,427 @@ func findTemplateFunctionsDocs(root string) [][]string { return docsList } +func embedExamples(root string, paths []string) { + if len(paths) == 0 { + logFatal(nil, "No Markdown files provided for example embedding") + } + + for _, path := range paths { + info, err := os.Stat(path) // #nosec G703 + if err != nil { + logFatal(err, "Failed to stat path %q", path) + } + if !info.IsDir() { + embedExamplesInMarkdown(root, path) + continue + } + if err = filepath.WalkDir(path, func(path string, entry fs.DirEntry, err error) error { // #nosec G703 + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".md" { + return nil + } + embedExamplesInMarkdown(root, path) + return nil + }); err != nil { + logFatal(err, "Failed to walk Markdown directory %q", path) + } + } +} + +func embedExamplesInMarkdown(root, path string) { + contents, err := os.ReadFile(path) // #nosec G304,G703 + if err != nil { + logFatal(err, "Failed to read Markdown file %q", path) + } + + updated := replaceEmbeddedExamples(root, string(contents), path) + updated = replaceGeneratedDocs(root, updated, path) + if updated == string(contents) { + return + } + if err = os.WriteFile(path, []byte(updated), 0o600); err != nil { // #nosec G703 + logFatal(err, "Failed to write Markdown file %q", path) + } +} + +func replaceGeneratedDocs(root, markdown, markdownPath string) string { + const ( + docsPrefix = "[//]: # (docs: " + docsSuffix = ")" + docsEnd = "[//]: # (end-docs)" + ) + + var builder strings.Builder + cursor := 0 + for { + directiveStart := strings.Index(markdown[cursor:], docsPrefix) + if directiveStart < 0 { + builder.WriteString(markdown[cursor:]) + return builder.String() + } + directiveStart += cursor + lineEnd := strings.IndexByte(markdown[directiveStart:], '\n') + if lineEnd < 0 { + logFatal(nil, "Docs directive in %q is not followed by an end directive", markdownPath) + } + lineEnd += directiveStart + directive := strings.TrimSpace(markdown[directiveStart:lineEnd]) + if !strings.HasPrefix(directive, docsPrefix) || !strings.HasSuffix(directive, docsSuffix) { + logFatal(nil, "Malformed docs directive %q in %q", directive, markdownPath) + } + docsRef := strings.TrimSuffix(strings.TrimPrefix(directive, docsPrefix), docsSuffix) + docs := renderGeneratedDocs(root, docsRef) + + endStart := strings.Index(markdown[lineEnd:], docsEnd) + if endStart < 0 { + logFatal(nil, "Docs directive %q in %q is missing %q", docsRef, markdownPath, docsEnd) + } + endStart += lineEnd + endLineEnd := strings.IndexByte(markdown[endStart:], '\n') + if endLineEnd < 0 { + endLineEnd = len(markdown) + } else { + endLineEnd += endStart + } + + builder.WriteString(markdown[cursor : lineEnd+1]) + builder.WriteByte('\n') + builder.WriteString(strings.TrimRight(docs, "\n")) + builder.WriteString("\n\n") + builder.WriteString(markdown[endStart:endLineEnd]) + cursor = endLineEnd + } +} + +type generatedDocsConfig struct { + path string + kind string + returns []string +} + +type documentedFunction struct { + name string + description string +} + +func renderGeneratedDocs(root, docsRef string) string { + config := parseGeneratedDocsConfig(docsRef) + if config.kind != "func" { + logFatal(nil, "Unsupported docs kind %q", config.kind) + } + + path := filepath.Join(root, filepath.FromSlash(config.path)) + var files []string + info, err := os.Stat(path) // #nosec G703 + if err != nil { + logFatal(err, "Failed to stat generated docs path %q", path) + } + if info.IsDir() { + if err = filepath.WalkDir(path, func(path string, entry fs.DirEntry, err error) error { // #nosec G703 + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { + return nil + } + files = append(files, path) + return nil + }); err != nil { + logFatal(err, "Failed to walk generated docs path %q", path) + } + } else { + files = append(files, path) + } + slices.Sort(files) + + functions := make([]documentedFunction, 0, len(files)) + for _, file := range files { + functions = append(functions, readDocumentedFunctions(file, config)...) + } + slices.SortFunc(functions, func(a, b documentedFunction) int { + return strings.Compare(a.name, b.name) + }) + + var builder strings.Builder + for _, function := range functions { + builder.WriteString("- `") + builder.WriteString(function.name) + builder.WriteString("` - ") + builder.WriteString(function.description) + builder.WriteByte('\n') + } + return builder.String() +} + +func parseGeneratedDocsConfig(docsRef string) generatedDocsConfig { + path, query, _ := strings.Cut(docsRef, "?") + config := generatedDocsConfig{path: path, kind: "func"} + for part := range strings.SplitSeq(query, "&") { + key, value, ok := strings.Cut(part, "=") + if !ok { + continue + } + switch key { + case "kind": + config.kind = value + case "returns": + config.returns = strings.Split(value, ",") + default: + logFatal(nil, "Unsupported docs query parameter %q", key) + } + } + return config +} + +func readDocumentedFunctions(path string, config generatedDocsConfig) []documentedFunction { + fset := token.NewFileSet() + astFile, err := parser.ParseFile(fset, path, nil, parser.ParseComments|parser.SkipObjectResolution) + if err != nil { + logFatal(err, "Failed to parse generated docs source %q", path) + } + + var functions []documentedFunction + for _, decl := range astFile.Decls { + funcDecl, ok := decl.(*ast.FuncDecl) + if !ok || !funcDecl.Name.IsExported() || !matchesReturnFilter(fset, funcDecl, config.returns) { + continue + } + if funcDecl.Doc == nil { + logFatal(nil, "Exported function %q in %q is missing documentation", funcDecl.Name.Name, path) + } + functions = append(functions, documentedFunction{ + name: funcDecl.Name.Name, + description: firstDocSentence(funcDecl.Doc), + }) + } + return functions +} + +func firstDocSentence(doc *ast.CommentGroup) string { + lines := make([]string, 0, len(doc.List)) + for _, comment := range doc.List { + line := strings.TrimSpace(strings.TrimPrefix(comment.Text, "//")) + if line == "" { + break + } + lines = append(lines, line) + } + return firstSentence(strings.Join(lines, " ")) +} + +func firstSentence(text string) string { + bracketDepth := 0 + for i, r := range text { + switch r { + case '[': + bracketDepth++ + case ']': + if bracketDepth > 0 { + bracketDepth-- + } + case '.': + if bracketDepth > 0 || isKnownAbbreviation(text[:i+1]) { + continue + } + if i == len(text)-1 || text[i+1] == ' ' || text[i+1] == '\n' || text[i+1] == '\t' { + return strings.TrimSpace(text[:i+1]) + } + } + } + return strings.TrimSpace(text) +} + +func isKnownAbbreviation(text string) bool { + lower := strings.ToLower(text) + return strings.HasSuffix(lower, "i.e.") || strings.HasSuffix(lower, "e.g.") +} + +func matchesReturnFilter(fset *token.FileSet, funcDecl *ast.FuncDecl, returns []string) bool { + if len(returns) == 0 { + return true + } + if funcDecl.Type.Results == nil { + return false + } + for _, result := range funcDecl.Type.Results.List { + var builder strings.Builder + if err := printer.Fprint(&builder, fset, result.Type); err != nil { + logFatal(err, "Failed to print return type for function %q", funcDecl.Name.Name) + } + returnType := builder.String() + for _, expected := range returns { + if strings.HasPrefix(returnType, expected+"[") || returnType == expected { + return true + } + } + } + return false +} + +func replaceEmbeddedExamples(root, markdown, markdownPath string) string { + const ( + embedPrefix = "[//]: # (embed: " + embedSuffix = ")" + goFence = "```go\n" + ) + + var builder strings.Builder + cursor := 0 + for { + directiveStart := strings.Index(markdown[cursor:], embedPrefix) + if directiveStart < 0 { + builder.WriteString(markdown[cursor:]) + return builder.String() + } + directiveStart += cursor + lineEnd := strings.IndexByte(markdown[directiveStart:], '\n') + if lineEnd < 0 { + logFatal(nil, "Embed directive in %q is not followed by a code block", markdownPath) + } + lineEnd += directiveStart + directive := strings.TrimSpace(markdown[directiveStart:lineEnd]) + if !strings.HasPrefix(directive, embedPrefix) || !strings.HasSuffix(directive, embedSuffix) { + logFatal(nil, "Malformed embed directive %q in %q", directive, markdownPath) + } + exampleRef := strings.TrimSuffix(strings.TrimPrefix(directive, embedPrefix), embedSuffix) + example := readEmbeddedExample(root, exampleRef) + + fenceStart := strings.Index(markdown[lineEnd:], goFence) + if fenceStart < 0 { + logFatal(nil, "Embed directive %q in %q is missing a Go code block", exampleRef, markdownPath) + } + fenceStart += lineEnd + codeStart := fenceStart + len(goFence) + fenceEnd := findCodeFenceEnd(markdown[codeStart:]) + if fenceEnd < 0 { + logFatal(nil, "Embed directive %q in %q has an unterminated Go code block", exampleRef, markdownPath) + } + fenceEnd += codeStart + + builder.WriteString(markdown[cursor:codeStart]) + builder.WriteString(strings.TrimRight(example, "\n")) + if markdown[fenceEnd] != '\n' { + builder.WriteByte('\n') + } + cursor = fenceEnd + } +} + +func findCodeFenceEnd(markdown string) int { + if strings.HasPrefix(markdown, "```") { + return 0 + } + if fenceEnd := strings.Index(markdown, "\n```"); fenceEnd >= 0 { + return fenceEnd + } + return strings.Index(markdown, "```") +} + +func readEmbeddedExample(root, exampleRef string) string { + sourcePath, functionName, hasFunctionName := strings.Cut(exampleRef, "#") + if hasFunctionName { + return readEmbeddedFunction(root, sourcePath, functionName) + } + if strings.HasPrefix(exampleRef, "Example") { + return readEmbeddedFunctionByName(root, exampleRef) + } + + contents, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(sourcePath))) // #nosec G304,G703 + if err != nil { + logFatal(err, "Failed to read embedded example %q", exampleRef) + } + return string(contents) +} + +func readEmbeddedFunctionByName(root, functionName string) string { + var matches []string + if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { // #nosec G703 + if err != nil { + return err + } + if entry.IsDir() || filepath.Base(path) != "example_test.go" { + return nil + } + if hasEmbeddedFunction(path, functionName) { + matches = append(matches, path) + } + return nil + }); err != nil { + logFatal(err, "Failed to find embedded example %q", functionName) + } + + switch len(matches) { + case 0: + logFatal(nil, "Function %q was not found in example_test.go files", functionName) + case 1: + relPath, err := filepath.Rel(root, matches[0]) + if err != nil { + logFatal(err, "Failed to resolve embedded example source %q", matches[0]) + } + return readEmbeddedFunction(root, filepath.ToSlash(relPath), functionName) + default: + logFatal( + nil, + "Function %q is ambiguous across example_test.go files: %s", + functionName, + strings.Join(matches, ", "), + ) + } + return "" +} + +func hasEmbeddedFunction(path, functionName string) bool { + fset := token.NewFileSet() + astFile, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + if err != nil { + logFatal(err, "Failed to parse embedded example source %q", path) + } + for _, decl := range astFile.Decls { + funcDecl, ok := decl.(*ast.FuncDecl) + if ok && funcDecl.Name.Name == functionName { + return true + } + } + return false +} + +func readEmbeddedFunction(root, sourcePath, functionName string) string { + path := filepath.Join(root, filepath.FromSlash(sourcePath)) + source, err := os.ReadFile(path) // #nosec G304,G703 + if err != nil { + logFatal(err, "Failed to read embedded example source %q", path) + } + + fset := token.NewFileSet() + astFile, err := parser.ParseFile(fset, path, source, parser.ParseComments|parser.SkipObjectResolution) + if err != nil { + logFatal(err, "Failed to parse embedded example source %q", path) + } + + for _, decl := range astFile.Decls { + funcDecl, ok := decl.(*ast.FuncDecl) + if !ok || funcDecl.Name.Name != functionName { + continue + } + startPos := funcDecl.Pos() + if funcDecl.Doc != nil { + startPos = funcDecl.Doc.Pos() + } + start := fset.PositionFor(startPos, false) + end := fset.PositionFor(funcDecl.End(), false) + return strings.TrimSpace(string(source[start.Offset:end.Offset])) + } + logFatal(nil, "Function %q was not found in embedded example source %q", functionName, sourcePath) + return "" +} + func logFatal(err error, msg string, a ...any) { var attrs []slog.Attr if err != nil { attrs = append(attrs, slog.String("error", err.Error())) } logging.Logger().LogAttrs(context.Background(), slog.LevelError, fmt.Sprintf(msg, a...), attrs...) + os.Exit(1) } diff --git a/pkg/govy/example_test.go b/pkg/govy/example_test.go index 3ead0400..580a1b61 100644 --- a/pkg/govy/example_test.go +++ b/pkg/govy/example_test.go @@ -446,7 +446,7 @@ func ExampleTransform() { // the property does not contain a zero value. // // Note: [govy.PropertyRules.Required] is introducing a short circuit. -// If the assertion fails, validation will stop and return [govy.govy.ErrorCodeRequired]. +// If the assertion fails, validation will stop and return [rules.ErrorCodeRequired]. // None of the rules you've defined would be evaluated. // // Note: Placement of [govy.PropertyRules.Required] does not matter, @@ -1400,7 +1400,7 @@ func ExampleForSlice_sliceOfPointers() { // // You can use [govy.ForMap] function to define rules for all the aforementioned iterators. // It returns a new struct [govy.PropertyRulesForMap] which behaves similar to -// [govy.PropertyRulesForSlice].. +// [govy.PropertyRulesForSlice]. // // To define rules for keys use: // - [govy.PropertyRulesForMap.RulesForKeys] @@ -1424,7 +1424,7 @@ func ExampleForSlice_sliceOfPointers() { // - Joan cannot be a teacher for student with index 918230013 (items). // // Notice that property path for maps has the following format: -// .. +// []. func ExampleForMap() { teacherValidator := govy.New( govy.For(func(t Teacher) string { return t.Name }). diff --git a/pkg/rules/comparable.go b/pkg/rules/comparable.go index 191a1c6e..db583a25 100644 --- a/pkg/rules/comparable.go +++ b/pkg/rules/comparable.go @@ -339,6 +339,8 @@ func isTemporal(v any) bool { } } +// GTComparableProperties ensures the first property's value is greater than the second property's value. +// It works with types that implement [Comparable], such as [time.Time]. func GTComparableProperties[T Comparable[T], P any]( firstName string, firstGetter func(parent P) T, @@ -366,6 +368,8 @@ func GTComparableProperties[T Comparable[T], P any]( WithDescription(fmt.Sprintf("'%s' must be greater than '%s'", firstName, secondName)) } +// GTEComparableProperties ensures the first property's value is greater than or equal to the second property's value. +// It works with types that implement [Comparable], such as [time.Time]. func GTEComparableProperties[T Comparable[T], P any]( firstName string, firstGetter func(parent P) T, @@ -393,6 +397,8 @@ func GTEComparableProperties[T Comparable[T], P any]( WithDescription(fmt.Sprintf("'%s' must be greater than or equal to '%s'", firstName, secondName)) } +// LTComparableProperties ensures the first property's value is less than the second property's value. +// It works with types that implement [Comparable], such as [time.Time]. func LTComparableProperties[T Comparable[T], P any]( firstName string, firstGetter func(parent P) T, @@ -420,6 +426,8 @@ func LTComparableProperties[T Comparable[T], P any]( WithDescription(fmt.Sprintf("'%s' must be less than '%s'", firstName, secondName)) } +// LTEComparableProperties ensures the first property's value is less than or equal to the second property's value. +// It works with types that implement [Comparable], such as [time.Time]. func LTEComparableProperties[T Comparable[T], P any]( firstName string, firstGetter func(parent P) T, diff --git a/scripts/embed-example-in-readme.bash b/scripts/embed-example-in-readme.bash deleted file mode 100755 index 293f2700..00000000 --- a/scripts/embed-example-in-readme.bash +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash - -set -e - -if [ "$#" -ne 1 ]; then - echo "Usage: $0 " - exit 1 -fi - -README_PATH="$1" - -# sed 's/\\/\\\\/g' is used to add extra backslashes which are eaten up by awk. -for file_embed in $(awk '/^\[\/\/\]: # \(embed: .*\)$/ {sub(/\)/, "", $4); print $4}' "$README_PATH"); do - echo "Found embed directive for: $file_embed" >&2 - awk \ - -v file_embed="$file_embed" \ - -v file_embed_contents="$(sed 's/\\/\\\\/g' "$file_embed")" \ - 'q==1 && /^```go$/ {q=2}; - q<2 {print} - $0 ~ file_embed {q=1} - q==2 && !/go/ && /^```$/ {q=0; printf "```go\n%s\n```\n", file_embed_contents};' \ - "$README_PATH" >"$README_PATH.tmp" - mv "$README_PATH.tmp" "$README_PATH" -done diff --git a/scripts/embed-examples-in-markdown.bash b/scripts/embed-examples-in-markdown.bash new file mode 100755 index 00000000..aac4b0ea --- /dev/null +++ b/scripts/embed-examples-in-markdown.bash @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -e + +if [ "$#" -lt 1 ]; then + echo "Usage: $0 ..." + exit 1 +fi + +go run ./internal/cmd/docextractor embed "$@"