vouch: v1.0.0 - initial release

This commit is contained in:
Timo Riegebauer 2025-03-31 08:59:10 +02:00
commit f89a72c054
8 changed files with 760 additions and 0 deletions

25
.gitignore vendored Normal file

@ -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

21
LICENSE Normal file

@ -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.

67
README.md Normal file

@ -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

566
default_rules.go Normal file

@ -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
}
}

3
go.mod Normal file

@ -0,0 +1,3 @@
module git.trcreatives.com/Aviary/go-validation
go 1.22.2

9
rule.go Normal file

@ -0,0 +1,9 @@
package vouch
import "reflect"
type Rule func(v any, kind reflect.Kind) error
func Rules(rules ...Rule) []Rule {
return rules
}

36
schema.go Normal file

@ -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
}

33
types.go Normal file

@ -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",
}