commit f89a72c0543ceacf0b5d3b87693b2d2e86407ed3 Author: Timo Riegebauer Date: Mon Mar 31 08:59:10 2025 +0200 vouch: v1.0.0 - initial release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64ae7f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..555526c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Timo Riegebauer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..86ff2d4 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Vouch + +`Vouch` is a lightweight, flexible validation library for Go, allowing you to define and enforce validation rules on struct fields with ease. + +## Installation + +```sh +go get git.trcreatives.com/triegebauer/go-vouch +``` + +## Usage + +### Defining a Schema + +A schema is a map of field names to validation rules. + +```go +package main + +import ( + "fmt" + "git.trcreatives.com/triegebauer/go-vouch" +) + +type User struct { + Name string + Age int + Email string +} + +func main() { + schema := vouch.Schema{ + "Name": vouch.Rules(vouch.Required(), vouch.MinLen(3)), + "Age": vouch.Rules(vouch.MinValue(18)), + "Email": vouch.Rules(vouch.Required(), vouch.Email()), + } + + user := User{Name: "", Age: 16, Email: "invalid-email"} + + if err := schema.Validate(user); err != nil { + fmt.Println("Validation error:", err) + } else { + fmt.Println("Validation passed!") + } +} +``` + +### Available Rules + +- `Required()` - Ensures the field is not empty. +- `MinLen(n int)` - Ensures the field has at least `n` characters. +- `MaxLen(n int)` - Ensures the field has at most `n` characters. +- `MinValue(n float64)` - Ensures the number is at least `n`. +- `MaxValue(n float64)` - Ensures the number is at most `n`. +- `Email()` - Ensures the field is a valid email. +- `URL()` - Ensures the field is a valid URL. +- `ContainsUpper()` - Ensures the field contains at least one uppercase letter. +- `ContainsDigit()` - Ensures the field contains at least one digit. +- ...and many more! + +## Contributing + +Feel free to open issues or submit pull requests to improve `vouch`. + +## License + +MIT License diff --git a/default_rules.go b/default_rules.go new file mode 100644 index 0000000..1f1e10f --- /dev/null +++ b/default_rules.go @@ -0,0 +1,566 @@ +package vouch + +import ( + "errors" + "fmt" + "net" + "net/url" + "reflect" + "regexp" + "strings" + "time" +) + +func typeMismatchError(expected, actual reflect.Kind) error { + return fmt.Errorf("expected type %s, but got %s", Types[expected], Types[actual]) +} + +func Required() Rule { + return func(v any, kind reflect.Kind) error { + val := reflect.ValueOf(v) + + if v == nil { + return fmt.Errorf("is required") + } + + switch kind { + case reflect.String, reflect.Array, reflect.Slice, reflect.Map: + if val.Len() == 0 { + return fmt.Errorf("is required") + } + case reflect.Ptr, reflect.Interface: + if val.IsNil() { + return fmt.Errorf("is required") + } + } + + return nil + } +} + +func MaxLen(n int) Rule { + return func(v any, kind reflect.Kind) error { + switch kind { + case reflect.String: + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + if len(val) > n { + return fmt.Errorf("must be at most %d characters long", n) + } + case reflect.Array, reflect.Slice, reflect.Map: + val := reflect.ValueOf(v) + if val.Kind() != kind { + return typeMismatchError(kind, val.Kind()) + } + + if val.Len() > n { + return fmt.Errorf("must have at most %d elements", n) + } + default: + return fmt.Errorf("unsupported type for MaxLen") + } + + return nil + } +} + +func MinLen(n int) Rule { + return func(v any, kind reflect.Kind) error { + switch kind { + case reflect.String: + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + if len(val) < n { + return fmt.Errorf("must be at least %d characters long", n) + } + case reflect.Array, reflect.Slice, reflect.Map: + val := reflect.ValueOf(v) + if val.Kind() != kind { + return typeMismatchError(kind, val.Kind()) + } + + if val.Len() < n { + return fmt.Errorf("must have at least %d elements", n) + } + default: + return fmt.Errorf("unsupported type for MinLen") + } + + return nil + } +} + +func ExactLen(n int) Rule { + return func(v any, kind reflect.Kind) error { + switch kind { + case reflect.String: + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + if len(val) != n { + return fmt.Errorf("must be exactly %d characters long", n) + } + case reflect.Array, reflect.Slice, reflect.Map: + val := reflect.ValueOf(v) + if val.Kind() != kind { + return typeMismatchError(kind, val.Kind()) + } + + if val.Len() != n { + return fmt.Errorf("must have exactly %d elements", n) + } + default: + return fmt.Errorf("unsupported type for ExactLen") + } + + return nil + } +} + +func MaxValue(n float64) Rule { + return func(v any, kind reflect.Kind) error { + val := reflect.ValueOf(v) + + switch kind { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if val.Int() > int64(n) { + return fmt.Errorf("must be at most %d", int64(n)) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if val.Uint() > uint64(n) { + return fmt.Errorf("must be at most %d", uint64(n)) + } + case reflect.Float32, reflect.Float64: + if val.Float() > float64(n) { + return fmt.Errorf("must be at most %f", n) + } + default: + return fmt.Errorf("unsupported type for MaxValue") + } + + return nil + } +} + +func MinValue(n float64) Rule { + return func(v any, kind reflect.Kind) error { + val := reflect.ValueOf(v) + + switch kind { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if val.Int() < int64(n) { + return fmt.Errorf("must be at least %d", int64(n)) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if val.Uint() < uint64(n) { + return fmt.Errorf("must be at least %d", uint64(n)) + } + case reflect.Float32, reflect.Float64: + if val.Float() < float64(n) { + return fmt.Errorf("must be at least %f", n) + } + default: + return fmt.Errorf("unsupported type for MinValue") + } + + return nil + } +} + +func GreaterThan(n float64) Rule { + return func(v any, kind reflect.Kind) error { + val := reflect.ValueOf(v) + + switch kind { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if val.Int() <= int64(n) { + return fmt.Errorf("must be greater than %d", int64(n)) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if val.Uint() <= uint64(n) { + return fmt.Errorf("must be greater than %d", uint64(n)) + } + case reflect.Float32, reflect.Float64: + if val.Float() <= float64(n) { + return fmt.Errorf("must be greater than %f", n) + } + default: + return fmt.Errorf("unsupported type for GreaterThan") + } + + return nil + } +} + +func LessThan(n float64) Rule { + return func(v any, kind reflect.Kind) error { + val := reflect.ValueOf(v) + + switch kind { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if val.Int() >= int64(n) { + return fmt.Errorf("must be less than %d", int64(n)) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if val.Uint() >= uint64(n) { + return fmt.Errorf("must be less than %d", uint64(n)) + } + case reflect.Float32, reflect.Float64: + if val.Float() >= float64(n) { + return fmt.Errorf("must be less than %f", n) + } + default: + return fmt.Errorf("unsupported type for LessThan") + } + + return nil + } +} + +func Between(min, max float64) Rule { + return func(v any, kind reflect.Kind) error { + val := reflect.ValueOf(v) + + switch kind { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if val.Int() < int64(min) || val.Int() > int64(max) { + return fmt.Errorf("must be between %d and %d", int64(min), int64(max)) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if val.Uint() < uint64(min) || val.Uint() > uint64(max) { + return fmt.Errorf("must be between %d and %d", uint64(min), uint64(max)) + } + case reflect.Float32, reflect.Float64: + if val.Float() < min || val.Float() > max { + return fmt.Errorf("must be between %f and %f", min, max) + } + default: + return fmt.Errorf("unsupported type for Between") + } + + return nil + } +} + +func RegexMatch(pattern string) Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + reg := regexp.MustCompile(pattern) + if !reg.MatchString(val) { + return errors.New("is in an invalid format") + } + + return nil + } +} + +func Email() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + if !emailRegex.MatchString(val) { + return errors.New("must be a valid email address") + } + + return nil + } +} + +func URL() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + if _, err := url.ParseRequestURI(val); err != nil { + return errors.New("must be a valid url") + } + + return nil + } +} + +func Alpha() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + emailRegex := regexp.MustCompile(`^[a-zA-Z]+$`) + if !emailRegex.MatchString(val) { + return errors.New("must contain only letters") + } + + return nil + } +} + +func Numeric() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + emailRegex := regexp.MustCompile(`^[0-9]+$`) + if !emailRegex.MatchString(val) { + return errors.New("must contain only numbers") + } + + return nil + } +} + +func Alphanumeric() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9]+$`) + if !emailRegex.MatchString(val) { + return errors.New("must contain only letters and numbers") + } + + return nil + } +} + +func ContainsUpper() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + upperRegex := regexp.MustCompile(`[A-Z]`) + if !upperRegex.MatchString(val) { + return errors.New("must contain at least one uppercase letter") + } + + return nil + } +} + +func ContainsLower() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + lowerRegex := regexp.MustCompile(`[a-z]`) + if !lowerRegex.MatchString(val) { + return errors.New("must contain at least one lowercase letter") + } + + return nil + } +} + +func ContainsDigit() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + digitRegex := regexp.MustCompile(`[0-9]`) + if !digitRegex.MatchString(val) { + return errors.New("must contain at least one digit") + } + + return nil + } +} + +func ContainsSpecial() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + specialRegex := regexp.MustCompile(`[^a-zA-Z0-9]`) + if !specialRegex.MatchString(val) { + return errors.New("must contain at least one special character") + } + + return nil + } +} + +func StartsWith(prefix string) Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + if !strings.HasPrefix(val, prefix) { + return fmt.Errorf("must start with '%s'", prefix) + } + + return nil + } +} + +func EndsWith(suffix string) Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + if !strings.HasSuffix(val, suffix) { + return fmt.Errorf("must end with '%s'", suffix) + } + + return nil + } +} + +func UUID() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + uuidRegex := regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + if !uuidRegex.MatchString(val) { + return errors.New("must be a valid UUID") + } + + return nil + } +} + +func IP() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + if net.ParseIP(val) == nil { + return errors.New("must be a valid IP address") + } + + return nil + } +} + +func IPv4() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + ip := net.ParseIP(val) + if ip == nil || ip.To4() == nil { + return errors.New("must be a valid IPv4 address") + } + + return nil + } +} + +func IPv6() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + ip := net.ParseIP(val) + if ip == nil || ip.To4() != nil { + return errors.New("must be a valid IPv6 address") + } + + return nil + } +} + +func Date() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + dateRegex := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + if !dateRegex.MatchString(val) { + return errors.New("must be a valid date in format YYYY-MM-DD") + } + + _, err := time.Parse("2006-01-02", val) + if err != nil { + return errors.New("must be a valid date") + } + + return nil + } +} + +func Time() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + timeRegex := regexp.MustCompile(`^\d{2}:\d{2}:\d{2}$`) + if !timeRegex.MatchString(val) { + return errors.New("must be a valid time in format HH:MM:SS") + } + + _, err := time.Parse("15:04:05", val) + if err != nil { + return errors.New("must be a valid time") + } + + return nil + } +} + +func DateTime() Rule { + return func(v any, kind reflect.Kind) error { + val, ok := v.(string) + if !ok { + return typeMismatchError(reflect.String, kind) + } + + dateTimeRegex := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$`) + if !dateTimeRegex.MatchString(val) { + return errors.New("must be a valid datetime in ISO 8601 format") + } + + _, err := time.Parse(time.RFC3339, val) + if err != nil { + return errors.New("must be a valid datetime") + } + + return nil + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5a70cc1 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.trcreatives.com/Aviary/go-validation + +go 1.22.2 diff --git a/rule.go b/rule.go new file mode 100644 index 0000000..dab9888 --- /dev/null +++ b/rule.go @@ -0,0 +1,9 @@ +package vouch + +import "reflect" + +type Rule func(v any, kind reflect.Kind) error + +func Rules(rules ...Rule) []Rule { + return rules +} diff --git a/schema.go b/schema.go new file mode 100644 index 0000000..76220fe --- /dev/null +++ b/schema.go @@ -0,0 +1,36 @@ +package vouch + +import ( + "fmt" + "reflect" +) + +type Schema map[string][]Rule + +func (s Schema) Validate(v any) error { + val := reflect.ValueOf(v) + + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + if val.Kind() != reflect.Struct { + panic("expected a struct") + } + + for i := range val.NumField() { + field := val.Field(i) + fieldType := val.Type().Field(i) + fieldName := fieldType.Name + + if rules, exists := s[fieldName]; exists { + for _, rule := range rules { + if err := rule(field.Interface(), field.Kind()); err != nil { + return fmt.Errorf("field '%s' %s", fieldName, err) + } + } + } + } + + return nil +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..a745c37 --- /dev/null +++ b/types.go @@ -0,0 +1,33 @@ +package vouch + +import "reflect" + +var Types = map[reflect.Kind]string{ + reflect.Invalid: "invalid", + reflect.Bool: "bool", + reflect.Int: "int", + reflect.Int8: "int8", + reflect.Int16: "int16", + reflect.Int32: "int32", + reflect.Int64: "int64", + reflect.Uint: "uint", + reflect.Uint8: "uint8", + reflect.Uint16: "uint16", + reflect.Uint32: "uint32", + reflect.Uint64: "uint64", + reflect.Uintptr: "uintptr", + reflect.Float32: "float32", + reflect.Float64: "float64", + reflect.Complex64: "complex64", + reflect.Complex128: "complex128", + reflect.Array: "array", + reflect.Chan: "chan", + reflect.Func: "func", + reflect.Interface: "interface", + reflect.Map: "map", + reflect.Pointer: "pointer", + reflect.Slice: "slice", + reflect.String: "string", + reflect.Struct: "struct", + reflect.UnsafePointer: "unsafe_pointer", +}