Add validator lib

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-11-03 08:19:09 +01:00
committed by Sacha Al Himdani
parent 288c59a5f2
commit f9216d30b2
102 changed files with 7409 additions and 1068 deletions

View File

@@ -0,0 +1,201 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"testing"
)
// CustomType simulates types like gid.GID
type CustomType string
func TestCheckEach_EmptyTypedSlice(t *testing.T) {
v := New()
// Simulate what happens with []gid.GID{} (empty slice of custom type)
emptySlice := []CustomType{}
v.CheckEach(emptySlice, "items", func(index int, item any) {
// This callback should never be called for an empty slice
t.Error("callback should not be called for empty slice")
})
if v.HasErrors() {
t.Errorf("unexpected error for empty slice: %v", v.Error())
}
}
func TestCheckEach_NonEmptyTypedSlice(t *testing.T) {
v := New()
// Simulate what happens with []gid.GID{"abc", "def"}
slice := []CustomType{"abc", "def"}
callCount := 0
v.CheckEach(slice, "items", func(index int, item any) {
callCount++
// Verify the item is the correct type
str, ok := item.(CustomType)
if !ok {
t.Errorf("expected CustomType, got %T", item)
}
if index == 0 && str != "abc" {
t.Errorf("expected 'abc', got %s", str)
}
if index == 1 && str != "def" {
t.Errorf("expected 'def', got %s", str)
}
})
if callCount != 2 {
t.Errorf("expected callback to be called 2 times, got %d", callCount)
}
if v.HasErrors() {
t.Errorf("unexpected error: %v", v.Error())
}
}
func TestCheckEach_NilTypedSlice(t *testing.T) {
v := New()
// Simulate what happens with var x []gid.GID (nil slice)
var nilSlice []CustomType
v.CheckEach(nilSlice, "items", func(index int, item any) {
// This callback should never be called for a nil slice
t.Error("callback should not be called for nil slice")
})
if v.HasErrors() {
t.Errorf("unexpected error for nil slice: %v", v.Error())
}
}
func TestCheckEach_PointerToNonEmptySlice(t *testing.T) {
v := New()
// Simulate what happens with *[]gid.GID (pointer to slice)
slice := []CustomType{"abc", "def", "ghi"}
ptrToSlice := &slice
callCount := 0
v.CheckEach(ptrToSlice, "items", func(index int, item any) {
callCount++
str, ok := item.(CustomType)
if !ok {
t.Errorf("expected CustomType, got %T", item)
}
expectedValues := []CustomType{"abc", "def", "ghi"}
if str != expectedValues[index] {
t.Errorf("at index %d: expected %s, got %s", index, expectedValues[index], str)
}
})
if callCount != 3 {
t.Errorf("expected callback to be called 3 times, got %d", callCount)
}
if v.HasErrors() {
t.Errorf("unexpected error for pointer to slice: %v", v.Error())
}
}
func TestCheckEach_PointerToEmptySlice(t *testing.T) {
v := New()
// Simulate what happens with *[]gid.GID{} (pointer to empty slice)
slice := []CustomType{}
ptrToSlice := &slice
v.CheckEach(ptrToSlice, "items", func(index int, item any) {
t.Error("callback should not be called for empty slice")
})
if v.HasErrors() {
t.Errorf("unexpected error for pointer to empty slice: %v", v.Error())
}
}
func TestCheckEach_NilPointerToSlice(t *testing.T) {
v := New()
// Simulate what happens with var x *[]gid.GID (nil pointer to slice)
var nilPtrToSlice *[]CustomType
v.CheckEach(nilPtrToSlice, "items", func(index int, item any) {
t.Error("callback should not be called for nil pointer to slice")
})
if v.HasErrors() {
t.Errorf("unexpected error for nil pointer to slice: %v", v.Error())
}
}
func TestCheckEach_DoublePointerToSlice(t *testing.T) {
v := New()
// Simulate what happens with **[]gid.GID (double pointer to slice)
slice := []CustomType{"x", "y"}
ptrToSlice := &slice
doublePtrToSlice := &ptrToSlice
callCount := 0
v.CheckEach(doublePtrToSlice, "items", func(index int, item any) {
callCount++
str, ok := item.(CustomType)
if !ok {
t.Errorf("expected CustomType, got %T", item)
}
expectedValues := []CustomType{"x", "y"}
if str != expectedValues[index] {
t.Errorf("at index %d: expected %s, got %s", index, expectedValues[index], str)
}
})
if callCount != 2 {
t.Errorf("expected callback to be called 2 times, got %d", callCount)
}
if v.HasErrors() {
t.Errorf("unexpected error for double pointer to slice: %v", v.Error())
}
}
func TestCheckEach_NonSliceValue(t *testing.T) {
v := New()
// Pass a non-slice value
notASlice := "this is a string"
v.CheckEach(notASlice, "items", func(index int, item any) {
t.Error("callback should not be called for non-slice value")
})
if !v.HasErrors() {
t.Error("expected error for non-slice value")
}
errors := v.Errors()
if len(errors) != 1 {
t.Errorf("expected 1 error, got %d", len(errors))
}
if errors[0].Code != ErrorCodeInvalidFormat {
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, errors[0].Code)
}
if errors[0].Message != "expected a slice" {
t.Errorf("expected message 'expected a slice', got '%s'", errors[0].Message)
}
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator_test
import (
"testing"
"go.probo.inc/probo/pkg/validator"
)
func TestDoublePointerValidation(t *testing.T) {
t.Run("valid double pointer string", func(t *testing.T) {
v := validator.New()
str := "hello"
ptr := &str
doublePtr := &ptr
v.Check(doublePtr, "name", validator.Required(), validator.NotEmpty(), validator.MaxLen(1000))
if v.HasErrors() {
t.Errorf("expected no errors, got: %v", v.Error())
}
})
t.Run("invalid double pointer string - empty", func(t *testing.T) {
v := validator.New()
str := ""
ptr := &str
doublePtr := &ptr
v.Check(doublePtr, "name", validator.Required(), validator.NotEmpty())
if !v.HasErrors() {
t.Error("expected errors for empty string")
}
})
t.Run("invalid double pointer string - too long", func(t *testing.T) {
v := validator.New()
str := "this is a very long string that exceeds the maximum length"
ptr := &str
doublePtr := &ptr
v.Check(doublePtr, "name", validator.Required(), validator.MaxLen(10))
if !v.HasErrors() {
t.Error("expected errors for string exceeding max length")
}
})
t.Run("optional double pointer - nil outer pointer", func(t *testing.T) {
v := validator.New()
var doublePtr **string = nil
v.Check(doublePtr, "name", validator.NotEmpty(), validator.MaxLen(1000))
if v.HasErrors() {
t.Errorf("expected no errors for nil optional field, got: %v", v.Error())
}
})
t.Run("optional double pointer - nil inner pointer", func(t *testing.T) {
v := validator.New()
var ptr *string = nil
doublePtr := &ptr
v.Check(doublePtr, "name", validator.NotEmpty(), validator.MaxLen(1000))
if v.HasErrors() {
t.Errorf("expected no errors for nil optional field, got: %v", v.Error())
}
})
t.Run("optional double pointer - valid value", func(t *testing.T) {
v := validator.New()
str := "hello"
ptr := &str
doublePtr := &ptr
v.Check(doublePtr, "name", validator.NotEmpty(), validator.MaxLen(1000))
if v.HasErrors() {
t.Errorf("expected no errors, got: %v", v.Error())
}
})
}

107
pkg/validator/errors.go Normal file
View File

@@ -0,0 +1,107 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"fmt"
"strings"
)
type ErrorCode string
const (
ErrorCodeRequired ErrorCode = "REQUIRED"
ErrorCodeInvalidFormat ErrorCode = "INVALID_FORMAT"
ErrorCodeOutOfRange ErrorCode = "OUT_OF_RANGE"
ErrorCodeTooShort ErrorCode = "TOO_SHORT"
ErrorCodeTooLong ErrorCode = "TOO_LONG"
ErrorCodeInvalidEmail ErrorCode = "INVALID_EMAIL"
ErrorCodeInvalidURL ErrorCode = "INVALID_URL"
ErrorCodeInvalidEnum ErrorCode = "INVALID_ENUM"
ErrorCodeInvalidGID ErrorCode = "INVALID_GID"
ErrorCodeUnsafeContent ErrorCode = "UNSAFE_CONTENT"
ErrorCodeCustom ErrorCode = "CUSTOM"
)
type ValidationError struct {
Field string
Code ErrorCode
Message string
Value any
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s %s", e.Field, e.Message)
}
type ValidationErrors []*ValidationError
func (ve ValidationErrors) Error() string {
if len(ve) == 0 {
return ""
}
var messages []string
for _, err := range ve {
messages = append(messages, err.Error())
}
return strings.Join(messages, "; ")
}
func (ve ValidationErrors) HasErrors() bool {
return len(ve) > 0
}
func (ve ValidationErrors) Fields() []string {
fields := make([]string, 0, len(ve))
for _, err := range ve {
fields = append(fields, err.Field)
}
return fields
}
func (ve ValidationErrors) ByField(field string) ValidationErrors {
var errors ValidationErrors
for _, err := range ve {
if err.Field == field {
errors = append(errors, err)
}
}
return errors
}
func (ve ValidationErrors) ByCode(code ErrorCode) ValidationErrors {
var errors ValidationErrors
for _, err := range ve {
if err.Code == code {
errors = append(errors, err)
}
}
return errors
}
func (ve ValidationErrors) First() *ValidationError {
if len(ve) == 0 {
return nil
}
return ve[0]
}
func newValidationError(code ErrorCode, message string) *ValidationError {
return &ValidationError{
Code: code,
Message: message,
}
}

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"testing"
)
// AssetType simulates coredata.AssetType
type AssetType string
const (
AssetTypePhysical AssetType = "PHYSICAL"
AssetTypeVirtual AssetType = "VIRTUAL"
)
func (at AssetType) String() string {
return string(at)
}
func TestOneOf_CustomStringType(t *testing.T) {
tests := []struct {
name string
value any
allowed []string
expectError bool
}{
{
name: "valid custom type - physical",
value: AssetTypePhysical,
allowed: []string{"PHYSICAL", "VIRTUAL"},
expectError: false,
},
{
name: "valid custom type - virtual",
value: AssetTypeVirtual,
allowed: []string{"PHYSICAL", "VIRTUAL"},
expectError: false,
},
{
name: "invalid custom type",
value: AssetType("INVALID"),
allowed: []string{"PHYSICAL", "VIRTUAL"},
expectError: true,
},
{
name: "custom type not in allowed list",
value: AssetTypePhysical,
allowed: []string{"VIRTUAL"},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := New()
v.Check(tt.value, "asset_type", OneOfSlice(tt.allowed))
if tt.expectError {
if !v.HasErrors() {
t.Error("expected error but got none")
}
} else {
if v.HasErrors() {
t.Errorf("unexpected error: %v", v.Error())
}
}
})
}
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"testing"
"go.probo.inc/probo/pkg/gid"
)
// CustomStringType simulates coredata.AssetType
type CustomStringType string
func (c CustomStringType) String() string {
return string(c)
}
func TestOptional_WithGIDPointer(t *testing.T) {
tenantID := gid.NewTenantID()
tests := []struct {
name string
value *gid.GID
expectError bool
}{
{
name: "nil pointer - should skip validation",
value: nil,
expectError: false,
},
{
name: "valid GID pointer",
value: func() *gid.GID {
g := gid.New(tenantID, 100)
return &g
}(),
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := New()
v.Check(tt.value, "owner_id", GID(100))
if tt.expectError {
if !v.HasErrors() {
t.Error("expected error but got none")
}
} else {
if v.HasErrors() {
t.Errorf("unexpected error: %v", v.Error())
}
}
})
}
}
func TestOptional_WithCustomTypePointer(t *testing.T) {
tests := []struct {
name string
value *CustomStringType
expectError bool
}{
{
name: "nil pointer - should skip validation",
value: nil,
expectError: false,
},
{
name: "valid custom type pointer",
value: func() *CustomStringType {
v := CustomStringType("VALID")
return &v
}(),
expectError: false,
},
{
name: "invalid custom type pointer",
value: func() *CustomStringType {
v := CustomStringType("INVALID")
return &v
}(),
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := New()
v.Check(tt.value, "asset_type", OneOf("VALID", "ANOTHER"))
if tt.expectError {
if !v.HasErrors() {
t.Error("expected error but got none")
}
} else {
if v.HasErrors() {
t.Errorf("unexpected error: %v", v.Error())
}
}
})
}
}

149
pkg/validator/validation.go Normal file
View File

@@ -0,0 +1,149 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"fmt"
"reflect"
)
type Validator struct {
errors ValidationErrors
}
func New() *Validator {
return &Validator{
errors: ValidationErrors{},
}
}
func (v *Validator) Check(value any, field string, validators ...ValidatorFunc) {
if len(validators) == 0 {
return
}
// Dereference pointer values to get the actual value for validation
actualValue := value
if value != nil {
val := reflect.ValueOf(value)
// Dereference all pointer levels
for val.Kind() == reflect.Ptr && !val.IsNil() {
val = val.Elem()
actualValue = val.Interface()
}
// If we ended up with a nil pointer at any level, set actualValue to nil
if val.Kind() == reflect.Ptr && val.IsNil() {
actualValue = nil
}
}
for _, validator := range validators {
if err := validator(actualValue); err != nil {
v.errors = append(v.errors, &ValidationError{
Field: field,
Code: err.Code,
Message: err.Message,
Value: value,
})
}
}
}
func (v *Validator) CheckEach(items any, field string, fn func(index int, item any)) {
if items == nil {
return
}
if slice, ok := items.([]any); ok {
for i, item := range slice {
fn(i, item)
}
return
}
val := reflect.ValueOf(items)
// Dereference pointer levels to get to the actual slice
for val.Kind() == reflect.Ptr {
if val.IsNil() {
return
}
val = val.Elem()
}
if val.Kind() != reflect.Slice {
v.errors = append(v.errors, &ValidationError{
Field: field,
Code: ErrorCodeInvalidFormat,
Message: "expected a slice",
Value: items,
})
return
}
for i := 0; i < val.Len(); i++ {
fn(i, val.Index(i).Interface())
}
}
func (v *Validator) CheckNested(field string, fn func(v *Validator)) {
nestedValidator := New()
fn(nestedValidator)
for _, err := range nestedValidator.errors {
prefixedErr := &ValidationError{
Field: fmt.Sprintf("%s.%s", field, err.Field),
Code: err.Code,
Message: err.Message,
Value: err.Value,
}
v.errors = append(v.errors, prefixedErr)
}
}
func (v *Validator) HasErrors() bool {
return len(v.errors) > 0
}
func (v *Validator) Errors() ValidationErrors {
return v.errors
}
func (v *Validator) Error() error {
if len(v.errors) == 0 {
return nil
}
return v.errors
}
type ValidatorFunc func(value any) *ValidationError
// dereferenceValue recursively dereferences all pointer levels.
// Returns the final dereferenced value and a boolean indicating if any pointer in the chain was nil.
func dereferenceValue(value any) (any, bool) {
if value == nil {
return nil, true
}
val := reflect.ValueOf(value)
// Dereference all pointer levels
for val.Kind() == reflect.Ptr {
if val.IsNil() {
return nil, true
}
val = val.Elem()
}
return val.Interface(), false
}

View File

@@ -0,0 +1,425 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"testing"
"time"
)
func BenchmarkValidate_SingleField(b *testing.B) {
email := "test@example.com"
b.ResetTimer()
for i := 0; i < b.N; i++ {
v := New()
v.Check(&email, "email", Required(), Email())
}
}
func BenchmarkValidate_MultipleFields(b *testing.B) {
email := "test@example.com"
password := "password123"
age := 25
b.ResetTimer()
for i := 0; i < b.N; i++ {
v := New()
v.Check(&email, "email", Required(), Email())
v.Check(&password, "password", Required(), MinLen(8))
v.Check(&age, "age", Min(18), Max(120))
}
}
func BenchmarkValidate_OptionalField(b *testing.B) {
var website *string
b.ResetTimer()
for i := 0; i < b.N; i++ {
v := New()
v.Check(website, "website", URL())
}
}
func BenchmarkValidate_NestedStruct(b *testing.B) {
type Address struct {
City string
ZipCode string
}
type User struct {
Name string
Address Address
}
user := User{
Name: "John Doe",
Address: Address{
City: "New York",
ZipCode: "10001",
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
v := New()
v.Check(&user.Name, "name", Required())
v.CheckNested("address", func(av *Validator) {
av.Check(&user.Address.City, "city", Required())
av.Check(&user.Address.ZipCode, "zipCode", Pattern(`^\d{5}$`, ""))
})
}
}
func BenchmarkValidate_ArrayValidation(b *testing.B) {
type Item struct {
Name string
Price int
}
items := []Item{
{Name: "Item 1", Price: 100},
{Name: "Item 2", Price: 200},
{Name: "Item 3", Price: 300},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
v := New()
for j, item := range items {
v.CheckNested("items[0]", func(iv *Validator) {
iv.Check(&item.Name, "name", Required())
iv.Check(&item.Price, "price", Min(0))
_ = j
})
}
}
}
func BenchmarkEmail(b *testing.B) {
email := "test@example.com"
validator := Email()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&email)
}
}
func BenchmarkURL(b *testing.B) {
urlStr := "https://example.com"
validator := URL()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&urlStr)
}
}
func BenchmarkUUID(b *testing.B) {
uuid := "550e8400-e29b-41d4-a716-446655440000"
validator := UUID()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&uuid)
}
}
func BenchmarkMinLen(b *testing.B) {
str := "hello world"
validator := MinLen(5)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&str)
}
}
func BenchmarkMin(b *testing.B) {
num := 42
validator := Min(18)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&num)
}
}
func BenchmarkMinFloat(b *testing.B) {
num := 99.99
validator := MinFloat(0.01)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&num)
}
}
func BenchmarkMaxFloat(b *testing.B) {
num := 50.50
validator := MaxFloat(99.99)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&num)
}
}
func BenchmarkRangeFloat(b *testing.B) {
num := 50.50
validator := RangeFloat(0.01, 99.99)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&num)
}
}
func BenchmarkNotEmpty(b *testing.B) {
str := "hello world"
validator := NotEmpty()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&str)
}
}
func BenchmarkPattern(b *testing.B) {
zipCode := "12345"
validator := Pattern(`^\d{5}$`, "")
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&zipCode)
}
}
func BenchmarkValidate_WithErrors(b *testing.B) {
email := "invalid-email"
b.ResetTimer()
for i := 0; i < b.N; i++ {
v := New()
v.Check(&email, "email", Required(), Email())
if !v.HasErrors() {
b.Fatal("expected validation error")
}
}
}
func BenchmarkValidate_ComplexForm(b *testing.B) {
type Address struct {
Street string
City string
ZipCode string
}
type User struct {
Email string
Name string
Age int
Website *string
PhoneNumber *string
Price float64
Address Address
}
website := "https://example.com"
user := User{
Email: "user@example.com",
Name: "John Doe",
Age: 30,
Website: &website,
PhoneNumber: nil,
Price: 99.99,
Address: Address{
Street: "123 Main St",
City: "New York",
ZipCode: "10001",
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
v := New()
v.Check(&user.Email, "email", Required(), Email())
v.Check(&user.Name, "name", Required(), MinLen(2))
v.Check(&user.Age, "age", Min(18), Max(120))
v.Check(user.Website, "website", URL())
v.Check(user.PhoneNumber, "phoneNumber", MinLen(10))
v.Check(&user.Price, "price", MinFloat(0.01))
v.CheckNested("address", func(av *Validator) {
av.Check(&user.Address.Street, "street", Required())
av.Check(&user.Address.City, "city", Required())
av.Check(&user.Address.ZipCode, "zipCode", Pattern(`^\d{5}$`, ""))
})
}
}
func BenchmarkMinItems(b *testing.B) {
items := []string{"a", "b", "c"}
validator := MinItems(2)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&items)
}
}
func BenchmarkMaxItems(b *testing.B) {
items := []string{"a", "b", "c"}
validator := MaxItems(5)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&items)
}
}
func BenchmarkUniqueItems(b *testing.B) {
items := []string{"a", "b", "c"}
validator := UniqueItems()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&items)
}
}
func BenchmarkAlphaNumeric(b *testing.B) {
str := "abc123DEF456"
validator := AlphaNumeric()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&str)
}
}
func BenchmarkNoSpaces(b *testing.B) {
str := "hello-world-test"
validator := NoSpaces()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&str)
}
}
func BenchmarkSlug(b *testing.B) {
str := "hello-world-123"
validator := Slug()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&str)
}
}
func BenchmarkAfter(b *testing.B) {
now := time.Now()
future := now.Add(24 * time.Hour)
validator := After(now)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&future)
}
}
func BenchmarkBefore(b *testing.B) {
now := time.Now()
past := now.Add(-24 * time.Hour)
validator := Before(now)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&past)
}
}
func BenchmarkFutureDate(b *testing.B) {
future := time.Now().Add(24 * time.Hour)
validator := FutureDate()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&future)
}
}
func BenchmarkPastDate(b *testing.B) {
past := time.Now().Add(-24 * time.Hour)
validator := PastDate()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&past)
}
}
func BenchmarkEqualTo(b *testing.B) {
str1 := "password"
str2 := "password"
validator := EqualTo(&str2)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&str1)
}
}
func BenchmarkNotEqualTo(b *testing.B) {
str1 := "password"
str2 := "different"
validator := NotEqualTo(&str2)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&str1)
}
}
func BenchmarkDomain(b *testing.B) {
str := "api.example.com"
validator := Domain()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&str)
}
}
func BenchmarkHTTPUrl(b *testing.B) {
str := "http://api.example.com/v1/users"
validator := HTTPUrl()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&str)
}
}
func BenchmarkHTTPSUrl(b *testing.B) {
str := "https://api.example.com/v1/users"
validator := HTTPSUrl()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = validator(&str)
}
}

View File

@@ -0,0 +1,451 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"fmt"
"testing"
"go.gearno.de/x/ref"
)
func TestValidator_Validate(t *testing.T) {
t.Run("single field validation", func(t *testing.T) {
v := New()
email := "test@example.com"
v.Check(&email, "email", Required(), Email())
if v.HasErrors() {
t.Errorf("expected no errors, got: %v", v.Errors())
}
})
t.Run("multiple field validations", func(t *testing.T) {
v := New()
email := ""
password := "123"
v.Check(&email, "email", Required(), Email())
v.Check(&password, "password", Required(), MinLen(8))
if !v.HasErrors() {
t.Error("expected validation errors")
}
errors := v.Errors()
// email: 1 error (Required), password: 1 error (MinLen - Required passes because it's not empty)
if len(errors) != 2 {
t.Errorf("expected 2 errors, got %d: %v", len(errors), errors)
}
})
t.Run("collect multiple errors for same field", func(t *testing.T) {
v := New()
value := "abc"
v.Check(&value, "password", MinLen(8), MaxLen(5))
errors := v.Errors()
// Both MinLen and MaxLen will fail (too short and somehow conflicts, but logically MinLen will fail)
if len(errors) < 1 {
t.Errorf("expected at least 1 error, got %d", len(errors))
}
})
}
func TestValidator_CheckNested(t *testing.T) {
v := New()
v.CheckNested("user", func(nv *Validator) {
email := "invalid"
nv.Check(&email, "email", Email())
nv.CheckNested("address", func(av *Validator) {
city := ""
av.Check(&city, "city", Required())
})
})
if !v.HasErrors() {
t.Error("expected validation errors")
}
errors := v.Errors()
if len(errors) != 2 {
t.Errorf("expected 2 errors, got %d", len(errors))
}
// Check field paths
expectedFields := map[string]bool{
"user.email": true,
"user.address.city": true,
}
for _, err := range errors {
if !expectedFields[err.Field] {
t.Errorf("unexpected field path: %s", err.Field)
}
}
}
func TestValidator_Error(t *testing.T) {
t.Run("no errors", func(t *testing.T) {
v := New()
if v.Error() != nil {
t.Errorf("expected nil error, got: %v", v.Error())
}
})
t.Run("with errors", func(t *testing.T) {
v := New()
email := ""
v.Check(&email, "email", Required())
err := v.Error()
if err == nil {
t.Error("expected error, got nil")
}
})
}
func TestValidationErrors_Methods(t *testing.T) {
errors := ValidationErrors{
{Field: "email", Code: ErrorCodeInvalidEmail, Message: "invalid email"},
{Field: "password", Code: ErrorCodeTooShort, Message: "too short"},
{Field: "email", Code: ErrorCodeRequired, Message: "required"},
}
t.Run("Fields", func(t *testing.T) {
fields := errors.Fields()
if len(fields) != 3 {
t.Errorf("expected 3 fields, got %d", len(fields))
}
})
t.Run("ByField", func(t *testing.T) {
emailErrors := errors.ByField("email")
if len(emailErrors) != 2 {
t.Errorf("expected 2 email errors, got %d", len(emailErrors))
}
})
t.Run("ByCode", func(t *testing.T) {
requiredErrors := errors.ByCode(ErrorCodeRequired)
if len(requiredErrors) != 1 {
t.Errorf("expected 1 required error, got %d", len(requiredErrors))
}
})
t.Run("First", func(t *testing.T) {
first := errors.First()
if first == nil {
t.Error("expected first error")
}
if first.Field != "email" {
t.Errorf("expected first field to be 'email', got '%s'", first.Field)
}
})
t.Run("Error", func(t *testing.T) {
errorStr := errors.Error()
if errorStr == "" {
t.Error("expected non-empty error string")
}
})
}
func TestOptionalFieldExample(t *testing.T) {
type CreateUserRequest struct {
Email string
Name string
Website *string
PhoneNumber *string
Age *int
}
website := "not-a-url"
req := CreateUserRequest{
Email: "user@example.com",
Name: "John Doe",
Website: &website,
PhoneNumber: nil,
Age: nil,
}
v := New()
v.Check(&req.Email, "email", Required(), Email())
v.Check(&req.Name, "name", Required(), MinLen(2))
v.Check(req.Website, "website", URL())
v.Check(req.PhoneNumber, "phoneNumber", MinLen(10))
v.Check(req.Age, "age", Min(18), Max(120))
if !v.HasErrors() {
t.Fatal("expected validation errors")
}
errors := v.Errors()
websiteErr := errors.ByField("website")
if len(websiteErr) != 1 {
t.Errorf("expected 1 website error, got %d", len(websiteErr))
}
phoneErr := errors.ByField("phoneNumber")
if len(phoneErr) != 0 {
t.Errorf("expected 0 phoneNumber errors (nil should be skipped), got %d", len(phoneErr))
}
ageErr := errors.ByField("age")
if len(ageErr) != 0 {
t.Errorf("expected 0 age errors (nil should be skipped), got %d", len(ageErr))
}
t.Logf("Optional field validation errors: %s", errors.Error())
}
func TestRealWorldExample(t *testing.T) {
// Simulate a user registration form
type Address struct {
City string
ZipCode string
}
type User struct {
Email string
Password string
Age int
Website *string
Address Address
}
user := User{
Email: "invalid-email",
Password: "123",
Age: 15,
Website: ref.Ref("not-a-url"),
Address: Address{
City: "",
ZipCode: "12345",
},
}
v := New()
// Validate user fields
v.Check(&user.Email, "email", Required(), Email())
v.Check(&user.Password, "password", Required(), MinLen(8))
v.Check(&user.Age, "age", Min(18), Max(120))
v.Check(user.Website, "website", URL())
// Validate nested address
v.CheckNested("address", func(av *Validator) {
av.Check(&user.Address.City, "city", Required())
av.Check(&user.Address.ZipCode, "zipCode", Pattern(`^\d{5}$`, "must be 5 digits"))
})
if !v.HasErrors() {
t.Fatal("expected validation errors")
}
errors := v.Errors()
expectedErrors := map[string]ErrorCode{
"email": ErrorCodeInvalidEmail,
"password": ErrorCodeTooShort,
"age": ErrorCodeOutOfRange,
"website": ErrorCodeInvalidURL,
"address.city": ErrorCodeRequired,
}
// Check that we have the expected errors
for field, expectedCode := range expectedErrors {
found := false
for _, err := range errors {
if err.Field == field && err.Code == expectedCode {
found = true
break
}
}
if !found {
t.Errorf("expected error for field '%s' with code '%s'", field, expectedCode)
}
}
// Print errors for debugging
t.Logf("Validation errors: %s", errors.Error())
}
func TestArrayValidation(t *testing.T) {
type Item struct {
Name string
Price int
}
items := []Item{
{Name: "", Price: -10},
{Name: "Valid", Price: 100},
{Name: "X", Price: 10},
}
v := New()
// Validate each item
for i, item := range items {
field := fmt.Sprintf("items[%d]", i)
v.CheckNested(field, func(iv *Validator) {
iv.Check(&item.Name, "name", Required(), MinLen(2))
iv.Check(&item.Price, "price", Min(0))
})
}
if !v.HasErrors() {
t.Fatal("expected validation errors")
}
errors := v.Errors()
// Check for specific field paths
expectedFields := []string{
"items[0].name",
"items[0].price",
"items[2].name",
}
for _, expectedField := range expectedFields {
found := false
for _, err := range errors {
if err.Field == expectedField {
found = true
break
}
}
if !found {
t.Errorf("expected error for field '%s'", expectedField)
}
}
t.Logf("Array validation errors: %s", errors.Error())
}
func TestDuplicateValidators(t *testing.T) {
t.Run("duplicate MinLen creates two errors", func(t *testing.T) {
v := New()
name := "abc"
v.Check(&name, "name", MinLen(5), MinLen(5))
if !v.HasErrors() {
t.Error("expected validation errors")
}
errors := v.Errors()
if len(errors) != 2 {
t.Errorf("expected 2 errors (one per MinLen), got %d", len(errors))
}
if errors[0].Message != "must be at least 5 characters" {
t.Errorf("unexpected first error: %s", errors[0].Message)
}
if errors[1].Message != "must be at least 5 characters" {
t.Errorf("unexpected second error: %s", errors[1].Message)
}
})
t.Run("duplicate Required creates two errors", func(t *testing.T) {
v := New()
name := ""
v.Check(&name, "name", Required(), Required())
errors := v.Errors()
if len(errors) != 2 {
t.Errorf("expected 2 errors, got %d", len(errors))
}
})
t.Run("duplicate Email creates two errors", func(t *testing.T) {
v := New()
email := "invalid"
v.Check(&email, "email", Email(), Email())
errors := v.Errors()
if len(errors) != 2 {
t.Errorf("expected 2 errors, got %d", len(errors))
}
})
t.Run("same validator with different parameters", func(t *testing.T) {
v := New()
name := "test"
v.Check(&name, "name", MinLen(5), MinLen(10))
errors := v.Errors()
if len(errors) != 2 {
t.Errorf("expected 2 errors, got %d", len(errors))
}
if errors[0].Message != "must be at least 5 characters" {
t.Errorf("unexpected first error: %s", errors[0].Message)
}
if errors[1].Message != "must be at least 10 characters" {
t.Errorf("unexpected second error: %s", errors[1].Message)
}
})
}
func TestStandardErrorPattern(t *testing.T) {
// Simulates a typical validation function
validateUser := func(email, password string) error {
v := New()
v.Check(&email, "email", Required(), Email())
v.Check(&password, "password", Required(), MinLen(8))
return v.Error()
}
t.Run("valid data returns nil", func(t *testing.T) {
err := validateUser("user@example.com", "password123")
if err != nil {
t.Errorf("expected nil, got: %v", err)
}
})
t.Run("invalid data returns ValidationErrors as error", func(t *testing.T) {
err := validateUser("", "123")
if err == nil {
t.Fatal("expected validation errors")
}
// Standard error handling
t.Logf("validation failed: %v", err)
// Can get detailed errors if needed
if validationErrs, ok := err.(ValidationErrors); ok {
for _, e := range validationErrs {
t.Logf(" - %s: %s (code: %s)", e.Field, e.Message, e.Code)
}
// Can use helper methods
emailErrs := validationErrs.ByField("email")
if len(emailErrs) != 1 {
t.Errorf("expected 1 email error, got %d", len(emailErrs))
}
passwordErrs := validationErrs.ByField("password")
if len(passwordErrs) != 1 {
t.Errorf("expected 1 password error, got %d", len(passwordErrs))
}
} else {
t.Error("expected ValidationErrors type")
}
})
}

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"fmt"
"reflect"
)
// MinItems validates that a slice or array has at least the specified minimum number of items.
func MinItems(min int) ValidatorFunc {
return func(value any) *ValidationError {
v := reflect.ValueOf(value)
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return nil
}
v = v.Elem()
}
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return newValidationError(ErrorCodeInvalidFormat, "value must be a slice or array")
}
if v.Len() < min {
return newValidationError(
ErrorCodeOutOfRange,
fmt.Sprintf("must contain at least %d items", min),
)
}
return nil
}
}
// MaxItems validates that a slice or array does not exceed the specified maximum number of items.
func MaxItems(max int) ValidatorFunc {
return func(value any) *ValidationError {
v := reflect.ValueOf(value)
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return nil
}
v = v.Elem()
}
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return newValidationError(ErrorCodeInvalidFormat, "value must be a slice or array")
}
if v.Len() > max {
return newValidationError(
ErrorCodeOutOfRange,
fmt.Sprintf("must contain at most %d items", max),
)
}
return nil
}
}
// UniqueItems validates that all items in a slice or array are unique.
func UniqueItems() ValidatorFunc {
return func(value any) *ValidationError {
v := reflect.ValueOf(value)
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return nil
}
v = v.Elem()
}
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
return newValidationError(ErrorCodeInvalidFormat, "value must be a slice or array")
}
if v.Len() == 0 {
return nil
}
elemType := v.Type().Elem()
if !elemType.Comparable() {
return newValidationError(ErrorCodeInvalidFormat, "cannot validate uniqueness for non-comparable types")
}
seen := make(map[any]bool)
for i := 0; i < v.Len(); i++ {
item := v.Index(i).Interface()
if seen[item] {
return newValidationError(ErrorCodeInvalidFormat, "items must be unique")
}
seen[item] = true
}
return nil
}
}

View File

@@ -0,0 +1,220 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"testing"
)
func TestMinItems(t *testing.T) {
t.Run("valid slice", func(t *testing.T) {
items := []string{"a", "b", "c"}
err := MinItems(2)(&items)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("exact minimum", func(t *testing.T) {
items := []int{1, 2}
err := MinItems(2)(&items)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("too few items", func(t *testing.T) {
items := []string{"a"}
err := MinItems(2)(&items)
if err == nil {
t.Error("expected validation error")
}
if err.Code != ErrorCodeOutOfRange {
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
}
})
t.Run("nil slice", func(t *testing.T) {
var items *[]string
err := MinItems(2)(items)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
t.Run("non-slice value", func(t *testing.T) {
value := "not a slice"
err := MinItems(2)(&value)
if err == nil || err.Code != ErrorCodeInvalidFormat {
t.Error("expected invalid format error")
}
})
}
func TestMaxItems(t *testing.T) {
t.Run("valid slice", func(t *testing.T) {
items := []string{"a", "b"}
err := MaxItems(5)(&items)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("exact maximum", func(t *testing.T) {
items := []int{1, 2, 3}
err := MaxItems(3)(&items)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("too many items", func(t *testing.T) {
items := []string{"a", "b", "c", "d"}
err := MaxItems(2)(&items)
if err == nil {
t.Error("expected validation error")
}
if err.Code != ErrorCodeOutOfRange {
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
}
})
t.Run("nil slice", func(t *testing.T) {
var items *[]string
err := MaxItems(2)(items)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
}
func TestUniqueItems(t *testing.T) {
t.Run("unique items", func(t *testing.T) {
items := []string{"a", "b", "c"}
err := UniqueItems()(&items)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("duplicate items", func(t *testing.T) {
items := []string{"a", "b", "a"}
err := UniqueItems()(&items)
if err == nil {
t.Error("expected validation error")
}
if err.Code != ErrorCodeInvalidFormat {
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
}
})
t.Run("unique integers", func(t *testing.T) {
items := []int{1, 2, 3}
err := UniqueItems()(&items)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("duplicate integers", func(t *testing.T) {
items := []int{1, 2, 1}
err := UniqueItems()(&items)
if err == nil {
t.Error("expected validation error")
}
})
t.Run("nil slice", func(t *testing.T) {
var items *[]string
err := UniqueItems()(items)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
t.Run("empty slice", func(t *testing.T) {
items := []string{}
err := UniqueItems()(&items)
if err != nil {
t.Errorf("expected no error for empty slice, got: %v", err)
}
})
t.Run("non-comparable type - slice of slices", func(t *testing.T) {
items := [][]int{{1, 2}, {3, 4}}
err := UniqueItems()(&items)
if err == nil {
t.Error("expected validation error for non-comparable type")
}
if err.Code != ErrorCodeInvalidFormat {
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
}
if err.Message != "cannot validate uniqueness for non-comparable types" {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("non-comparable type - slice of maps", func(t *testing.T) {
items := []map[string]int{{"a": 1}, {"b": 2}}
err := UniqueItems()(&items)
if err == nil {
t.Error("expected validation error for non-comparable type")
}
if err.Code != ErrorCodeInvalidFormat {
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
}
})
t.Run("non-comparable type - struct with slice field", func(t *testing.T) {
type NonComparable struct {
Items []int
}
items := []NonComparable{{Items: []int{1, 2}}, {Items: []int{3, 4}}}
err := UniqueItems()(&items)
if err == nil {
t.Error("expected validation error for non-comparable type")
}
if err.Code != ErrorCodeInvalidFormat {
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
}
})
t.Run("comparable struct with unique values", func(t *testing.T) {
type ComparableStruct struct {
ID int
Name string
}
items := []ComparableStruct{{ID: 1, Name: "a"}, {ID: 2, Name: "b"}}
err := UniqueItems()(&items)
if err != nil {
t.Errorf("expected no error for comparable structs, got: %v", err)
}
})
t.Run("comparable struct with duplicate values", func(t *testing.T) {
type ComparableStruct struct {
ID int
Name string
}
items := []ComparableStruct{{ID: 1, Name: "a"}, {ID: 2, Name: "b"}, {ID: 1, Name: "a"}}
err := UniqueItems()(&items)
if err == nil {
t.Error("expected validation error for duplicate comparable structs")
}
if err.Code != ErrorCodeInvalidFormat {
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
}
})
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"reflect"
"strings"
)
// Required validates that a field has a value.
// For strings, it also checks that the value is not empty or just whitespace.
// For slices, it checks that the slice is not empty.
func Required() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return newValidationError(ErrorCodeRequired, "field is required")
}
switch v := actualValue.(type) {
case string:
if strings.TrimSpace(v) == "" {
return newValidationError(ErrorCodeRequired, "field is required")
}
default:
rv := reflect.ValueOf(actualValue)
if rv.Kind() == reflect.Slice && rv.Len() == 0 {
return newValidationError(ErrorCodeRequired, "field is required")
}
}
return nil
}
}
// NotEmpty validates that a field is not empty.
// Similar to Required, but can be used independently.
func NotEmpty() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
switch v := actualValue.(type) {
case string:
if strings.TrimSpace(v) == "" {
return newValidationError(ErrorCodeRequired, "field cannot be empty")
}
default:
rv := reflect.ValueOf(actualValue)
if rv.Kind() == reflect.Slice && rv.Len() == 0 {
return newValidationError(ErrorCodeRequired, "field cannot be empty")
}
}
return nil
}
}

View File

@@ -0,0 +1,270 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"testing"
)
func TestOptionalByDefault(t *testing.T) {
t.Run("nil value skips validation by default", func(t *testing.T) {
v := New()
v.Check(nil, "field", MinLen(5))
if v.HasErrors() {
t.Errorf("expected no errors for nil (optional by default), got: %v", v.Errors())
}
})
t.Run("nil pointer skips validation by default", func(t *testing.T) {
v := New()
var str *string
v.Check(str, "field", MinLen(5))
if v.HasErrors() {
t.Errorf("expected no errors for nil pointer (optional by default), got: %v", v.Errors())
}
})
t.Run("valid value passes validation", func(t *testing.T) {
v := New()
str := "hello world"
v.Check(&str, "field", MinLen(5))
if v.HasErrors() {
t.Errorf("expected no errors, got: %v", v.Errors())
}
})
t.Run("invalid value fails validation", func(t *testing.T) {
v := New()
str := "hi"
v.Check(&str, "field", MinLen(5))
if !v.HasErrors() {
t.Error("expected validation error")
}
})
t.Run("multiple validators", func(t *testing.T) {
v := New()
str := "hello"
v.Check(&str, "field", MinLen(3), MaxLen(10))
if v.HasErrors() {
t.Errorf("expected no errors, got: %v", v.Errors())
}
})
t.Run("empty string is not nil and gets validated", func(t *testing.T) {
v := New()
str := ""
v.Check(&str, "field", MinLen(5))
if !v.HasErrors() {
t.Error("expected validation error for empty string")
}
})
t.Run("Required() validates nil values", func(t *testing.T) {
v := New()
var str *string
v.Check(str, "field", Required())
if !v.HasErrors() {
t.Error("expected validation error for nil with Required()")
}
})
}
func TestRequired(t *testing.T) {
t.Run("valid string", func(t *testing.T) {
str := "hello"
err := Required()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("empty string", func(t *testing.T) {
str := ""
err := Required()(&str)
if err == nil {
t.Error("expected validation error")
}
if err.Code != ErrorCodeRequired {
t.Errorf("expected error code %s, got %s", ErrorCodeRequired, err.Code)
}
})
t.Run("whitespace string", func(t *testing.T) {
str := " "
err := Required()(&str)
if err == nil {
t.Error("expected validation error for whitespace")
}
})
t.Run("nil string pointer", func(t *testing.T) {
var str *string
err := Required()(str)
if err == nil {
t.Error("expected validation error for nil pointer")
}
})
t.Run("valid string pointer", func(t *testing.T) {
str := "hello"
err := Required()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("nil interface", func(t *testing.T) {
err := Required()(nil)
if err == nil {
t.Error("expected validation error for nil")
}
})
t.Run("zero int", func(t *testing.T) {
num := 0
err := Required()(&num)
if err != nil {
t.Errorf("expected no error for zero int, got: %v", err)
}
})
t.Run("positive int", func(t *testing.T) {
num := 42
err := Required()(&num)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("nil int pointer", func(t *testing.T) {
var num *int
err := Required()(num)
if err == nil {
t.Error("expected validation error for nil int pointer")
}
})
t.Run("valid int pointer", func(t *testing.T) {
num := 42
err := Required()(&num)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("empty slice", func(t *testing.T) {
slice := []any{}
err := Required()(slice)
if err == nil {
t.Error("expected validation error for empty slice")
}
})
t.Run("non-empty slice", func(t *testing.T) {
slice := []any{1, 2, 3}
err := Required()(slice)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("empty string slice", func(t *testing.T) {
slice := []string{}
err := Required()(slice)
if err == nil {
t.Error("expected validation error for empty []string slice")
}
if err.Code != ErrorCodeRequired {
t.Errorf("expected error code %s, got %s", ErrorCodeRequired, err.Code)
}
})
t.Run("non-empty string slice", func(t *testing.T) {
slice := []string{"a", "b", "c"}
err := Required()(slice)
if err != nil {
t.Errorf("expected no error for non-empty []string, got: %v", err)
}
})
t.Run("empty int slice", func(t *testing.T) {
slice := []int{}
err := Required()(slice)
if err == nil {
t.Error("expected validation error for empty []int slice")
}
if err.Code != ErrorCodeRequired {
t.Errorf("expected error code %s, got %s", ErrorCodeRequired, err.Code)
}
})
t.Run("non-empty int slice", func(t *testing.T) {
slice := []int{1, 2, 3}
err := Required()(slice)
if err != nil {
t.Errorf("expected no error for non-empty []int, got: %v", err)
}
})
t.Run("empty custom type slice", func(t *testing.T) {
type CustomType struct {
ID int
}
slice := []CustomType{}
err := Required()(slice)
if err == nil {
t.Error("expected validation error for empty custom type slice")
}
if err.Code != ErrorCodeRequired {
t.Errorf("expected error code %s, got %s", ErrorCodeRequired, err.Code)
}
})
t.Run("non-empty custom type slice", func(t *testing.T) {
type CustomType struct {
ID int
}
slice := []CustomType{{ID: 1}, {ID: 2}}
err := Required()(slice)
if err != nil {
t.Errorf("expected no error for non-empty custom type slice, got: %v", err)
}
})
t.Run("empty pointer slice", func(t *testing.T) {
slice := []*string{}
err := Required()(slice)
if err == nil {
t.Error("expected validation error for empty []*string slice")
}
})
t.Run("non-empty pointer slice", func(t *testing.T) {
str1, str2 := "a", "b"
slice := []*string{&str1, &str2}
err := Required()(slice)
if err != nil {
t.Errorf("expected no error for non-empty []*string, got: %v", err)
}
})
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"reflect"
"time"
)
// EqualTo validates that a value equals another value using deep equality.
// Special handling for time.Time to compare instants rather than internal structure.
func EqualTo(other any) ValidatorFunc {
return func(value any) *ValidationError {
if !areEqual(value, other) {
return newValidationError(ErrorCodeInvalidFormat, "values must match")
}
return nil
}
}
// NotEqualTo validates that a value does not equal another value using deep equality.
// Special handling for time.Time to compare instants rather than internal structure.
func NotEqualTo(other any) ValidatorFunc {
return func(value any) *ValidationError {
if areEqual(value, other) {
return newValidationError(ErrorCodeInvalidFormat, "values must not match")
}
return nil
}
}
// areEqual compares two values for equality with special handling for time.Time.
func areEqual(a, b any) bool {
// Dereference both values
aVal, aIsNil := dereferenceValue(a)
bVal, bIsNil := dereferenceValue(b)
// If both are nil, they're equal
if aIsNil && bIsNil {
return true
}
// If only one is nil, they're not equal
if aIsNil || bIsNil {
return false
}
// Special handling for time.Time
aTime, aIsTime := aVal.(time.Time)
bTime, bIsTime := bVal.(time.Time)
if aIsTime && bIsTime {
// Use time.Time.Equal() which compares the instant, ignoring location and monotonic clock
return aTime.Equal(bTime)
}
// Fall back to reflect.DeepEqual for all other types
return reflect.DeepEqual(aVal, bVal)
}

View File

@@ -0,0 +1,182 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"testing"
"time"
)
func TestEqualTo(t *testing.T) {
t.Run("equal strings", func(t *testing.T) {
str1 := "password"
str2 := "password"
err := EqualTo(&str2)(&str1)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("different strings", func(t *testing.T) {
str1 := "password"
str2 := "different"
err := EqualTo(&str2)(&str1)
if err == nil {
t.Error("expected validation error")
}
})
t.Run("equal integers", func(t *testing.T) {
num1 := 42
num2 := 42
err := EqualTo(&num2)(&num1)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("different integers", func(t *testing.T) {
num1 := 42
num2 := 43
err := EqualTo(&num2)(&num1)
if err == nil {
t.Error("expected validation error")
}
})
}
func TestNotEqualTo(t *testing.T) {
t.Run("different strings", func(t *testing.T) {
str1 := "password"
str2 := "different"
err := NotEqualTo(&str2)(&str1)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("equal strings", func(t *testing.T) {
str1 := "password"
str2 := "password"
err := NotEqualTo(&str2)(&str1)
if err == nil {
t.Error("expected validation error")
}
})
}
func TestEqualTo_TimeComparison(t *testing.T) {
t.Run("same instant same location", func(t *testing.T) {
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
time2 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
err := EqualTo(time2)(time1)
if err != nil {
t.Errorf("expected no error for same instant, got: %v", err)
}
})
t.Run("same instant different location", func(t *testing.T) {
// Create the same instant in different time zones
utcTime := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
est, _ := time.LoadLocation("America/New_York")
estTime := time.Date(2025, 11, 5, 7, 0, 0, 0, est) // 7am EST = 12pm UTC
err := EqualTo(utcTime)(estTime)
if err != nil {
t.Errorf("expected no error for same instant in different locations, got: %v", err)
}
})
t.Run("different instants same location", func(t *testing.T) {
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
time2 := time.Date(2025, 11, 5, 13, 0, 0, 0, time.UTC)
err := EqualTo(time2)(time1)
if err == nil {
t.Error("expected validation error for different instants")
}
})
t.Run("pointer to time same instant", func(t *testing.T) {
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
time2 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
err := EqualTo(&time2)(&time1)
if err != nil {
t.Errorf("expected no error for pointer to same instant, got: %v", err)
}
})
t.Run("nil time pointers", func(t *testing.T) {
var time1 *time.Time
var time2 *time.Time
err := EqualTo(time2)(time1)
if err != nil {
t.Errorf("expected no error for nil time pointers, got: %v", err)
}
})
t.Run("one nil one non-nil time pointer", func(t *testing.T) {
var time1 *time.Time
time2 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
err := EqualTo(&time2)(time1)
if err == nil {
t.Error("expected validation error for nil vs non-nil time")
}
})
t.Run("same instant with monotonic clock difference", func(t *testing.T) {
// Simulate times with different monotonic clock data
baseTime := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
time1 := baseTime
time.Sleep(1 * time.Millisecond) // Advances monotonic clock
time2 := baseTime
// Even though monotonic clocks differ, the instants are the same
err := EqualTo(time2)(time1)
if err != nil {
t.Errorf("expected no error despite monotonic clock difference, got: %v", err)
}
})
}
func TestNotEqualTo_TimeComparison(t *testing.T) {
t.Run("different instants", func(t *testing.T) {
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
time2 := time.Date(2025, 11, 5, 13, 0, 0, 0, time.UTC)
err := NotEqualTo(time2)(time1)
if err != nil {
t.Errorf("expected no error for different instants, got: %v", err)
}
})
t.Run("same instant same location", func(t *testing.T) {
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
time2 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
err := NotEqualTo(time2)(time1)
if err == nil {
t.Error("expected validation error for same instant")
}
})
t.Run("same instant different location", func(t *testing.T) {
utcTime := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
est, _ := time.LoadLocation("America/New_York")
estTime := time.Date(2025, 11, 5, 7, 0, 0, 0, est)
err := NotEqualTo(utcTime)(estTime)
if err == nil {
t.Error("expected validation error for same instant in different locations")
}
})
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
// Custom creates a custom validator with a specified error code, message, and validation function.
// The validation function should return true if the value is valid, false otherwise.
func Custom(code ErrorCode, message string, fn func(value any) bool) ValidatorFunc {
return func(value any) *ValidationError {
if !fn(value) {
return newValidationError(code, message)
}
return nil
}
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"testing"
)
func TestCustom(t *testing.T) {
validator := Custom(ErrorCodeCustom, "value must be positive", func(value any) bool {
if num, ok := value.(int); ok {
return num > 0
}
return false
})
tests := []struct {
name string
value any
wantError bool
}{
{"valid positive", 5, false},
{"invalid zero", 0, true},
{"invalid negative", -5, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator(tt.value)
if (err != nil) != tt.wantError {
t.Errorf("Custom() error = %v, wantError %v", err, tt.wantError)
}
})
}
}

View File

@@ -0,0 +1,255 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"net/url"
"regexp"
"go.probo.inc/probo/pkg/gid"
)
var (
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
gidRegex = regexp.MustCompile(`^gid://[a-zA-Z0-9\-_]+/[a-zA-Z0-9\-_]+/[a-zA-Z0-9\-_]+$`)
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}$`)
domainRegex = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
)
// Email validates that a string is a valid email address.
func Email() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidEmail, "value must be a string")
}
if str == "" {
return nil
}
if !emailRegex.MatchString(str) {
return newValidationError(ErrorCodeInvalidEmail, "invalid email address")
}
return nil
}
}
// URL validates that a string is a valid URL with http or https scheme.
func URL() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidURL, "value must be a string")
}
if str == "" {
return nil
}
parsedURL, err := url.Parse(str)
if err != nil {
return newValidationError(ErrorCodeInvalidURL, "invalid URL format")
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return newValidationError(ErrorCodeInvalidURL, "URL must use http or https scheme")
}
if parsedURL.Host == "" {
return newValidationError(ErrorCodeInvalidURL, "URL must have a host")
}
return nil
}
}
// HTTPUrl validates that a string is a valid HTTP URL (not HTTPS).
func HTTPUrl() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidURL, "value must be a string")
}
if str == "" {
return nil
}
parsedURL, err := url.Parse(str)
if err != nil {
return newValidationError(ErrorCodeInvalidURL, "invalid URL format")
}
if parsedURL.Scheme != "http" {
return newValidationError(ErrorCodeInvalidURL, "URL must use http scheme")
}
if parsedURL.Host == "" {
return newValidationError(ErrorCodeInvalidURL, "URL must have a host")
}
return nil
}
}
// HTTPSUrl validates that a string is a valid HTTPS URL (not HTTP).
func HTTPSUrl() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidURL, "value must be a string")
}
if str == "" {
return nil
}
parsedURL, err := url.Parse(str)
if err != nil {
return newValidationError(ErrorCodeInvalidURL, "invalid URL format")
}
if parsedURL.Scheme != "https" {
return newValidationError(ErrorCodeInvalidURL, "URL must use https scheme")
}
if parsedURL.Host == "" {
return newValidationError(ErrorCodeInvalidURL, "URL must have a host")
}
return nil
}
}
// UUID validates that a string is a valid UUID.
func UUID() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
}
if str == "" {
return nil
}
if !uuidRegex.MatchString(str) {
return newValidationError(ErrorCodeInvalidFormat, "invalid UUID format")
}
return nil
}
}
// GID validates that a string is a valid GID using gid.ParseGID.
// Optionally validates the entity type if provided.
//
// Example usage:
// - GID() validates any GID format
// - GID(100) validates GID with entity type 100
// - GID(100, 200) validates GID with entity type 100 or 200
func GID(entityTypes ...uint16) ValidatorFunc {
return func(value any) *ValidationError {
if value == nil {
return nil
}
var gidValue gid.GID
switch v := value.(type) {
case gid.GID:
gidValue = v
case *gid.GID:
if v == nil {
return nil
}
gidValue = *v
default:
return newValidationError(ErrorCodeInvalidGID, "value must be a GID")
}
if len(entityTypes) > 0 {
parsedEntityType := gidValue.EntityType()
valid := false
for _, expected := range entityTypes {
if parsedEntityType == expected {
valid = true
break
}
}
if !valid {
return newValidationError(ErrorCodeInvalidGID, "GID has invalid entity type")
}
}
return nil
}
}
// Domain validates that a string is a valid domain name.
func Domain() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
}
if str == "" {
return nil
}
if len(str) > 253 {
return newValidationError(ErrorCodeInvalidFormat, "domain name too long (max 253 characters)")
}
if !domainRegex.MatchString(str) {
return newValidationError(ErrorCodeInvalidFormat, "invalid domain name format")
}
return nil
}
}

View File

@@ -0,0 +1,432 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"strings"
"testing"
"go.probo.inc/probo/pkg/gid"
)
func TestEmail(t *testing.T) {
tests := []struct {
name string
value any
wantError bool
}{
{"valid email", "test@example.com", false},
{"valid email with plus", "test+tag@example.com", false},
{"invalid email no @", "testexample.com", true},
{"invalid email no domain", "test@", true},
{"invalid email no TLD", "test@example", true},
{"empty string", "", false}, // Empty is allowed, use Required() to enforce
{"nil pointer", (*string)(nil), false}, // Skip validation
{"non-string", 123, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Email()(tt.value)
if (err != nil) != tt.wantError {
t.Errorf("Email() error = %v, wantError %v", err, tt.wantError)
}
if err != nil && err.Code != ErrorCodeInvalidEmail {
t.Errorf("Expected error code %s, got %s", ErrorCodeInvalidEmail, err.Code)
}
})
}
}
func TestURL(t *testing.T) {
tests := []struct {
name string
value any
wantError bool
}{
{"valid http URL", "http://example.com", false},
{"valid https URL", "https://example.com", false},
{"valid URL with path", "https://example.com/path", false},
{"invalid scheme", "ftp://example.com", true},
{"no scheme", "example.com", true},
{"no host", "https://", true},
{"empty string", "", false}, // Empty is allowed
{"nil pointer", (*string)(nil), false},
{"non-string", 123, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := URL()(tt.value)
if (err != nil) != tt.wantError {
t.Errorf("URL() error = %v, wantError %v", err, tt.wantError)
}
if err != nil && err.Code != ErrorCodeInvalidURL {
t.Errorf("Expected error code %s, got %s", ErrorCodeInvalidURL, err.Code)
}
})
}
}
func TestHTTPUrl(t *testing.T) {
t.Run("valid http URL", func(t *testing.T) {
str := "http://example.com"
err := HTTPUrl()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid http URL with path", func(t *testing.T) {
str := "http://example.com/path/to/resource"
err := HTTPUrl()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid http URL with query", func(t *testing.T) {
str := "http://example.com?foo=bar"
err := HTTPUrl()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("invalid - https scheme", func(t *testing.T) {
str := "https://example.com"
err := HTTPUrl()(&str)
if err == nil {
t.Error("expected validation error for https")
}
if err.Message != "URL must use http scheme" {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - no scheme", func(t *testing.T) {
str := "example.com"
err := HTTPUrl()(&str)
if err == nil {
t.Error("expected validation error for missing scheme")
}
})
t.Run("invalid - no host", func(t *testing.T) {
str := "http://"
err := HTTPUrl()(&str)
if err == nil {
t.Error("expected validation error for missing host")
}
})
t.Run("empty string", func(t *testing.T) {
str := ""
err := HTTPUrl()(&str)
if err != nil {
t.Errorf("expected no error for empty string, got: %v", err)
}
})
t.Run("nil pointer", func(t *testing.T) {
var str *string
err := HTTPUrl()(str)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
}
func TestHTTPSUrl(t *testing.T) {
t.Run("valid https URL", func(t *testing.T) {
str := "https://example.com"
err := HTTPSUrl()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid https URL with path", func(t *testing.T) {
str := "https://example.com/path/to/resource"
err := HTTPSUrl()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid https URL with query", func(t *testing.T) {
str := "https://api.example.com/v1/users?page=1"
err := HTTPSUrl()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("invalid - http scheme", func(t *testing.T) {
str := "http://example.com"
err := HTTPSUrl()(&str)
if err == nil {
t.Error("expected validation error for http")
}
if err.Message != "URL must use https scheme" {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - ftp scheme", func(t *testing.T) {
str := "ftp://example.com"
err := HTTPSUrl()(&str)
if err == nil {
t.Error("expected validation error for ftp")
}
})
t.Run("invalid - no scheme", func(t *testing.T) {
str := "example.com"
err := HTTPSUrl()(&str)
if err == nil {
t.Error("expected validation error for missing scheme")
}
})
t.Run("invalid - no host", func(t *testing.T) {
str := "https://"
err := HTTPSUrl()(&str)
if err == nil {
t.Error("expected validation error for missing host")
}
})
t.Run("empty string", func(t *testing.T) {
str := ""
err := HTTPSUrl()(&str)
if err != nil {
t.Errorf("expected no error for empty string, got: %v", err)
}
})
t.Run("nil pointer", func(t *testing.T) {
var str *string
err := HTTPSUrl()(str)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
}
func TestDomain(t *testing.T) {
t.Run("valid domain", func(t *testing.T) {
str := "example.com"
err := Domain()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid subdomain", func(t *testing.T) {
str := "api.example.com"
err := Domain()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid nested subdomain", func(t *testing.T) {
str := "api.v1.example.com"
err := Domain()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid domain with hyphens", func(t *testing.T) {
str := "my-api.example-site.com"
err := Domain()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("single word domain", func(t *testing.T) {
str := "localhost"
err := Domain()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("invalid - starts with hyphen", func(t *testing.T) {
str := "-example.com"
err := Domain()(&str)
if err == nil {
t.Error("expected validation error for domain starting with hyphen")
}
})
t.Run("invalid - ends with hyphen", func(t *testing.T) {
str := "example-.com"
err := Domain()(&str)
if err == nil {
t.Error("expected validation error for domain ending with hyphen")
}
})
t.Run("invalid - contains underscore", func(t *testing.T) {
str := "example_site.com"
err := Domain()(&str)
if err == nil {
t.Error("expected validation error for underscore")
}
})
t.Run("invalid - contains spaces", func(t *testing.T) {
str := "example site.com"
err := Domain()(&str)
if err == nil {
t.Error("expected validation error for spaces")
}
})
t.Run("invalid - empty label", func(t *testing.T) {
str := "example..com"
err := Domain()(&str)
if err == nil {
t.Error("expected validation error for empty label")
}
})
t.Run("invalid - too long", func(t *testing.T) {
str := strings.Repeat("a", 254)
err := Domain()(&str)
if err == nil {
t.Error("expected validation error for domain too long")
}
if err.Message != "domain name too long (max 253 characters)" {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("empty string", func(t *testing.T) {
str := ""
err := Domain()(&str)
if err != nil {
t.Errorf("expected no error for empty string, got: %v", err)
}
})
t.Run("nil pointer", func(t *testing.T) {
var str *string
err := Domain()(str)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
}
func TestGID(t *testing.T) {
// Create a valid GID for testing
tenantID := gid.TenantID([8]byte{1, 2, 3, 4, 5, 6, 7, 8})
validGID := gid.New(tenantID, 100)
t.Run("valid GID type - no entity type validation", func(t *testing.T) {
err := GID()(validGID)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid GID type - with matching entity type", func(t *testing.T) {
err := GID(100)(validGID)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid GID type - with multiple entity types", func(t *testing.T) {
err := GID(100, 200, 300)(validGID)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("invalid - wrong entity type", func(t *testing.T) {
err := GID(200)(validGID)
if err == nil {
t.Error("expected validation error for wrong entity type")
}
if err.Code != ErrorCodeInvalidGID {
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidGID, err.Code)
}
if err.Message != "GID has invalid entity type" {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - wrong entity type with multiple options", func(t *testing.T) {
err := GID(200, 300)(validGID)
if err == nil {
t.Error("expected validation error for wrong entity type")
}
})
t.Run("valid - entity type matches one of multiple options", func(t *testing.T) {
err := GID(99, 100, 101)(validGID)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("nil GID pointer", func(t *testing.T) {
var gidPtr *gid.GID
err := GID()(gidPtr)
if err != nil {
t.Errorf("expected no error for nil GID pointer, got: %v", err)
}
})
t.Run("valid GID pointer", func(t *testing.T) {
err := GID()(&validGID)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid GID pointer with entity type validation", func(t *testing.T) {
err := GID(100)(&validGID)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("non-GID type", func(t *testing.T) {
err := GID()(123)
if err == nil {
t.Error("expected validation error for non-GID type")
}
if err.Message != "value must be a GID" {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("string type not supported", func(t *testing.T) {
err := GID()("some-string")
if err == nil {
t.Error("expected validation error for string type")
}
if err.Message != "value must be a GID" {
t.Errorf("unexpected error message: %s", err.Message)
}
})
}

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import "fmt"
// Min validates that a number is at least the specified minimum value.
func Min(min int) ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
var num int
switch v := actualValue.(type) {
case int:
num = v
case int32:
num = int(v)
case int64:
num = int(v)
default:
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
}
if num < min {
return newValidationError(
ErrorCodeOutOfRange,
fmt.Sprintf("must be at least %d", min),
)
}
return nil
}
}
// Max validates that a number does not exceed the specified maximum value.
func Max(max int) ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
var num int
switch v := actualValue.(type) {
case int:
num = v
case int32:
num = int(v)
case int64:
num = int(v)
default:
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
}
if num > max {
return newValidationError(
ErrorCodeOutOfRange,
fmt.Sprintf("must be at most %d", max),
)
}
return nil
}
}
// Range validates that a number is within the specified range (inclusive).
func Range(min, max int) ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
var num int
switch v := actualValue.(type) {
case int:
num = v
case int32:
num = int(v)
case int64:
num = int(v)
default:
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
}
if num < min || num > max {
return newValidationError(
ErrorCodeOutOfRange,
fmt.Sprintf("must be between %d and %d", min, max),
)
}
return nil
}
}
// MinFloat validates that a floating-point number is at least the specified minimum value.
func MinFloat(min float64) ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
var num float64
switch v := actualValue.(type) {
case float32:
num = float64(v)
case float64:
num = v
case int:
num = float64(v)
default:
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
}
if num < min {
return newValidationError(
ErrorCodeOutOfRange,
fmt.Sprintf("must be at least %g", min),
)
}
return nil
}
}
// MaxFloat validates that a floating-point number does not exceed the specified maximum value.
func MaxFloat(max float64) ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
var num float64
switch v := actualValue.(type) {
case float32:
num = float64(v)
case float64:
num = v
case int:
num = float64(v)
default:
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
}
if num > max {
return newValidationError(
ErrorCodeOutOfRange,
fmt.Sprintf("must be at most %g", max),
)
}
return nil
}
}
// RangeFloat validates that a floating-point number is within the specified range (inclusive).
func RangeFloat(min, max float64) ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
var num float64
switch v := actualValue.(type) {
case float32:
num = float64(v)
case float64:
num = v
case int:
num = float64(v)
default:
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
}
if num < min || num > max {
return newValidationError(
ErrorCodeOutOfRange,
fmt.Sprintf("must be between %g and %g", min, max),
)
}
return nil
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"testing"
"go.gearno.de/x/ref"
)
func TestMin(t *testing.T) {
tests := []struct {
name string
value any
min int
wantError bool
}{
{"valid int", 10, 5, false},
{"exact min", 5, 5, false},
{"below min", 3, 5, true},
{"valid int pointer", ref.Ref(10), 5, false},
{"nil pointer", (*int)(nil), 5, false}, // Skip validation
{"non-numeric", "test", 5, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Min(tt.min)(tt.value)
if (err != nil) != tt.wantError {
t.Errorf("Min() error = %v, wantError %v", err, tt.wantError)
}
})
}
}
func TestMax(t *testing.T) {
tests := []struct {
name string
value any
max int
wantError bool
}{
{"valid int", 5, 10, false},
{"exact max", 10, 10, false},
{"above max", 15, 10, true},
{"valid int pointer", ref.Ref(5), 10, false},
{"nil pointer", (*int)(nil), 10, false}, // Skip validation
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Max(tt.max)(tt.value)
if (err != nil) != tt.wantError {
t.Errorf("Max() error = %v, wantError %v", err, tt.wantError)
}
})
}
}
func TestRange(t *testing.T) {
tests := []struct {
name string
value any
min int
max int
wantError bool
}{
{"in range", 5, 1, 10, false},
{"at min", 1, 1, 10, false},
{"at max", 10, 1, 10, false},
{"below range", 0, 1, 10, true},
{"above range", 11, 1, 10, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Range(tt.min, tt.max)(tt.value)
if (err != nil) != tt.wantError {
t.Errorf("Range() error = %v, wantError %v", err, tt.wantError)
}
})
}
}

View File

@@ -0,0 +1,173 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"fmt"
"regexp"
"strings"
)
var (
htmlTagRegex = regexp.MustCompile(`<[^>]*>`)
)
// NoHTML validates that a string does not contain HTML tags or angle brackets.
// It rejects:
// - HTML tags (e.g., <script>, <b>, <div>, etc.)
// - Angle brackets (< and >) even when not part of complete tags
//
// This helps prevent XSS attacks and ensures user input doesn't contain HTML markup.
// Combine with PrintableText() for comprehensive text field validation.
func NoHTML() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
}
if str == "" {
return nil
}
// Check for HTML tags first (more specific error message)
if htmlTagRegex.MatchString(str) {
return newValidationError(ErrorCodeInvalidFormat, "must not contain HTML tags")
}
// Check for angle brackets (even without complete tags)
if strings.ContainsAny(str, "<>") {
return newValidationError(ErrorCodeInvalidFormat, "must not contain angle brackets")
}
return nil
}
}
// PrintableText validates that a string contains only printable UTF-8 characters.
// It rejects:
// - Control characters (including null bytes, tabs, line breaks except space)
// - Unicode direction override characters (RLO, LRO, PDF, etc.)
// - Zero-width characters (ZWSP, ZWNJ, ZWJ, etc.)
// - Other invisible or formatting characters
// - Private use area characters
// - Replacement characters
//
// This validator does NOT check for HTML tags - use NoHTML() for that.
// This is ideal for validating titles, full names, display names, and similar text fields
// where only printable characters should be allowed.
func PrintableText() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
}
if str == "" {
return nil
}
// Check each rune for invisible or problematic characters
for i, r := range str {
// Allow normal space
if r == ' ' {
continue
}
// Reject control characters (0x00-0x1F and 0x7F-0x9F)
if r < 0x20 || (r >= 0x7F && r < 0xA0) {
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains invalid control character at position %d", i))
}
// Reject Unicode direction override and formatting characters
// U+200E LEFT-TO-RIGHT MARK (LRM)
// U+200F RIGHT-TO-LEFT MARK (RLM)
// U+202A LEFT-TO-RIGHT EMBEDDING (LRE)
// U+202B RIGHT-TO-LEFT EMBEDDING (RLE)
// U+202C POP DIRECTIONAL FORMATTING (PDF)
// U+202D LEFT-TO-RIGHT OVERRIDE (LRO)
// U+202E RIGHT-TO-LEFT OVERRIDE (RLO)
// U+2066 LEFT-TO-RIGHT ISOLATE (LRI)
// U+2067 RIGHT-TO-LEFT ISOLATE (RLI)
// U+2068 FIRST STRONG ISOLATE (FSI)
// U+2069 POP DIRECTIONAL ISOLATE (PDI)
if r >= 0x200E && r <= 0x200F || r >= 0x202A && r <= 0x202E || r >= 0x2066 && r <= 0x2069 {
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains bidirectional override character at position %d", i))
}
// Reject zero-width characters
// U+200B ZERO WIDTH SPACE (ZWSP)
// U+200C ZERO WIDTH NON-JOINER (ZWNJ)
// U+200D ZERO WIDTH JOINER (ZWJ)
// U+FEFF ZERO WIDTH NO-BREAK SPACE (BOM)
if r == 0x200B || r == 0x200C || r == 0x200D || r == 0xFEFF {
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains zero-width character at position %d", i))
}
// Reject other format characters (Cf category)
// U+00AD SOFT HYPHEN
// U+2060 WORD JOINER
// U+180E MONGOLIAN VOWEL SEPARATOR (deprecated but still problematic)
if r == 0x00AD || r == 0x2060 || r == 0x180E {
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains invisible formatting character at position %d", i))
}
// Reject private use area characters (often used for exploits)
// U+E000-U+F8FF Private Use Area
// U+F0000-U+FFFFD Supplementary Private Use Area-A
// U+100000-U+10FFFD Supplementary Private Use Area-B
if (r >= 0xE000 && r <= 0xF8FF) || (r >= 0xF0000 && r <= 0xFFFFD) || (r >= 0x100000 && r <= 0x10FFFD) {
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains private use character at position %d", i))
}
// Reject replacement character (often indicates encoding issues)
if r == 0xFFFD {
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains replacement character at position %d", i))
}
}
return nil
}
}
// SafeText validates that a string is non-empty, bounded, and contains only safe content.
// It combines NotEmpty, MaxLen, NoHTML, and PrintableText validators.
func SafeText(maxLen int) ValidatorFunc {
validators := []ValidatorFunc{
NotEmpty(),
MaxLen(maxLen),
NoHTML(),
PrintableText(),
}
return func(value any) *ValidationError {
for _, validator := range validators {
if err := validator(value); err != nil {
return err
}
}
return nil
}
}

View File

@@ -0,0 +1,755 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"strings"
"testing"
)
func TestNoHTML(t *testing.T) {
t.Run("valid text without HTML", func(t *testing.T) {
str := "This is a normal text"
err := NoHTML()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid text with special characters", func(t *testing.T) {
str := "Price: $10.99 - 20% off!"
err := NoHTML()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid UTF-8 text", func(t *testing.T) {
str := "José García 张伟"
err := NoHTML()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid text with emojis", func(t *testing.T) {
str := "Hello World 🌍"
err := NoHTML()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("invalid - script tag XSS", func(t *testing.T) {
str := "<script>alert('xss')</script>"
err := NoHTML()(&str)
if err == nil {
t.Error("expected validation error for script tag")
}
if !strings.Contains(err.Message, "HTML tags") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - simple bold tag", func(t *testing.T) {
str := "Hello <b>World</b>"
err := NoHTML()(&str)
if err == nil {
t.Error("expected validation error for bold tag")
}
if !strings.Contains(err.Message, "HTML tags") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - div tag", func(t *testing.T) {
str := "<div>Content</div>"
err := NoHTML()(&str)
if err == nil {
t.Error("expected validation error for div tag")
}
})
t.Run("invalid - self-closing tag", func(t *testing.T) {
str := "Line break<br/>here"
err := NoHTML()(&str)
if err == nil {
t.Error("expected validation error for self-closing tag")
}
})
t.Run("invalid - img tag", func(t *testing.T) {
str := `<img src="x" onerror="alert(1)">`
err := NoHTML()(&str)
if err == nil {
t.Error("expected validation error for img tag")
}
})
t.Run("invalid - anchor tag", func(t *testing.T) {
str := `<a href="javascript:alert(1)">Click</a>`
err := NoHTML()(&str)
if err == nil {
t.Error("expected validation error for anchor tag")
}
})
t.Run("invalid - less than symbol", func(t *testing.T) {
str := "5 < 10"
err := NoHTML()(&str)
if err == nil {
t.Error("expected validation error for angle bracket")
}
if !strings.Contains(err.Message, "angle brackets") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - greater than symbol", func(t *testing.T) {
str := "10 > 5"
err := NoHTML()(&str)
if err == nil {
t.Error("expected validation error for angle bracket")
}
})
t.Run("invalid - both angle brackets", func(t *testing.T) {
str := "5 < x > 10"
err := NoHTML()(&str)
if err == nil {
t.Error("expected validation error for angle brackets")
}
})
t.Run("invalid - malformed tag", func(t *testing.T) {
str := "text <incomplete"
err := NoHTML()(&str)
if err == nil {
t.Error("expected validation error for incomplete tag")
}
})
t.Run("invalid - encoded attempt", func(t *testing.T) {
str := "<ScRiPt>alert(1)</ScRiPt>"
err := NoHTML()(&str)
if err == nil {
t.Error("expected validation error for mixed case script tag")
}
})
t.Run("empty string", func(t *testing.T) {
str := ""
err := NoHTML()(&str)
if err != nil {
t.Errorf("expected no error for empty string, got: %v", err)
}
})
t.Run("nil pointer", func(t *testing.T) {
var str *string
err := NoHTML()(str)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
t.Run("not a string", func(t *testing.T) {
num := 123
err := NoHTML()(&num)
if err == nil {
t.Error("expected validation error for non-string")
}
if !strings.Contains(err.Message, "must be a string") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("combined with other validators", func(t *testing.T) {
v := New()
title := "Product Title 2024"
v.Check(&title, "title", Required(), NoHTML(), MinLen(3), MaxLen(100))
if v.HasErrors() {
t.Errorf("expected no errors, got: %v", v.Errors())
}
})
t.Run("combined with PrintableText", func(t *testing.T) {
v := New()
title := "José García-O'Brien"
v.Check(&title, "title", Required(), NoHTML(), PrintableText(), MinLen(3), MaxLen(100))
if v.HasErrors() {
t.Errorf("expected no errors, got: %v", v.Errors())
}
})
t.Run("combined validators catch XSS", func(t *testing.T) {
v := New()
malicious := "<script>alert('xss')</script>"
v.Check(&malicious, "content", Required(), NoHTML(), PrintableText())
if !v.HasErrors() {
t.Error("expected validation errors")
}
// Should have error from NoHTML
errors := v.Errors()
found := false
for _, err := range errors {
if strings.Contains(err.Message, "HTML tags") || strings.Contains(err.Message, "angle brackets") {
found = true
break
}
}
if !found {
t.Error("expected error about HTML tags or angle brackets")
}
})
t.Run("combined validators catch invisible chars and HTML", func(t *testing.T) {
v := New()
malicious := "<b>test\x00text</b>"
v.Check(&malicious, "content", NoHTML(), PrintableText())
if !v.HasErrors() {
t.Error("expected validation errors")
}
// Should have at least one error (NoHTML will catch it first)
if len(v.Errors()) < 1 {
t.Error("expected at least one validation error")
}
})
}
func TestPrintableText(t *testing.T) {
t.Run("valid UTF-8 text with accents", func(t *testing.T) {
str := "José García"
err := PrintableText()(&str)
if err != nil {
t.Errorf("expected no error for valid UTF-8 name, got: %v", err)
}
})
t.Run("valid text with emojis", func(t *testing.T) {
str := "Hello World 🌍"
err := PrintableText()(&str)
if err != nil {
t.Errorf("expected no error for emojis, got: %v", err)
}
})
t.Run("valid Chinese characters", func(t *testing.T) {
str := "张伟"
err := PrintableText()(&str)
if err != nil {
t.Errorf("expected no error for Chinese characters, got: %v", err)
}
})
t.Run("valid Arabic text", func(t *testing.T) {
str := "محمد"
err := PrintableText()(&str)
if err != nil {
t.Errorf("expected no error for Arabic text, got: %v", err)
}
})
t.Run("valid Cyrillic text", func(t *testing.T) {
str := "Александр"
err := PrintableText()(&str)
if err != nil {
t.Errorf("expected no error for Cyrillic text, got: %v", err)
}
})
t.Run("valid text with apostrophe and hyphen", func(t *testing.T) {
str := "O'Brien-Smith"
err := PrintableText()(&str)
if err != nil {
t.Errorf("expected no error for apostrophe and hyphen, got: %v", err)
}
})
t.Run("valid text with numbers", func(t *testing.T) {
str := "Product 2024"
err := PrintableText()(&str)
if err != nil {
t.Errorf("expected no error for text with numbers, got: %v", err)
}
})
t.Run("valid text with punctuation", func(t *testing.T) {
str := "Hello, World! How are you?"
err := PrintableText()(&str)
if err != nil {
t.Errorf("expected no error for punctuation, got: %v", err)
}
})
t.Run("valid text with angle brackets", func(t *testing.T) {
str := "5 < 10 > 3"
err := PrintableText()(&str)
if err != nil {
t.Errorf("expected no error for angle brackets (HTML checking is separate), got: %v", err)
}
})
t.Run("invalid - RLO character", func(t *testing.T) {
str := "test\u202Eexe.txt"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for RLO character")
}
if !strings.Contains(err.Message, "bidirectional override") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - LRO character", func(t *testing.T) {
str := "test\u202Dtext"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for LRO character")
}
})
t.Run("invalid - zero-width space", func(t *testing.T) {
str := "test\u200Btext"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for zero-width space")
}
if !strings.Contains(err.Message, "zero-width") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - zero-width non-joiner", func(t *testing.T) {
str := "test\u200Ctext"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for zero-width non-joiner")
}
})
t.Run("invalid - zero-width joiner", func(t *testing.T) {
str := "test\u200Dtext"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for zero-width joiner")
}
})
t.Run("invalid - BOM character", func(t *testing.T) {
str := "\uFEFFtest"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for BOM character")
}
})
t.Run("invalid - null byte", func(t *testing.T) {
str := "test\x00text"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for null byte")
}
if !strings.Contains(err.Message, "control character") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - tab character", func(t *testing.T) {
str := "test\ttext"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for tab character")
}
})
t.Run("invalid - newline character", func(t *testing.T) {
str := "test\ntext"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for newline character")
}
})
t.Run("invalid - carriage return", func(t *testing.T) {
str := "test\rtext"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for carriage return")
}
})
t.Run("invalid - soft hyphen", func(t *testing.T) {
str := "test\u00ADtext"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for soft hyphen")
}
if !strings.Contains(err.Message, "invisible formatting") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - word joiner", func(t *testing.T) {
str := "test\u2060text"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for word joiner")
}
})
t.Run("invalid - private use area character", func(t *testing.T) {
str := "test\uE000text"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for private use area")
}
if !strings.Contains(err.Message, "private use") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - replacement character", func(t *testing.T) {
str := "test\uFFFDtext"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for replacement character")
}
if !strings.Contains(err.Message, "replacement character") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - DEL control character", func(t *testing.T) {
str := "test\x7Ftext"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for DEL control character")
}
})
t.Run("invalid - C1 control character", func(t *testing.T) {
str := "test\u0080text"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for C1 control character")
}
})
t.Run("invalid - LTR mark", func(t *testing.T) {
str := "test\u200Etext"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for LTR mark")
}
})
t.Run("invalid - RTL mark", func(t *testing.T) {
str := "test\u200Ftext"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error for RTL mark")
}
})
t.Run("valid with pointer", func(t *testing.T) {
str := "Valid Name"
err := PrintableText()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("empty string", func(t *testing.T) {
str := ""
err := PrintableText()(&str)
if err != nil {
t.Errorf("expected no error for empty string, got: %v", err)
}
})
t.Run("nil pointer", func(t *testing.T) {
var str *string
err := PrintableText()(str)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
t.Run("not a string", func(t *testing.T) {
num := 123
err := PrintableText()(&num)
if err == nil {
t.Error("expected validation error for non-string")
}
if !strings.Contains(err.Message, "must be a string") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("combined with other validators", func(t *testing.T) {
v := New()
title := "Product Title 2024"
v.Check(&title, "title", Required(), PrintableText(), MinLen(3), MaxLen(100))
if v.HasErrors() {
t.Errorf("expected no errors, got: %v", v.Errors())
}
})
t.Run("position reported correctly", func(t *testing.T) {
str := "abc\x00def"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error")
}
if !strings.Contains(err.Message, "position 3") {
t.Errorf("expected position 3 in error message, got: %s", err.Message)
}
})
t.Run("UTF-8 position counting", func(t *testing.T) {
// Test that position is counted correctly with UTF-8 characters
// The range loop in Go iterates by runes, so position will be rune index
str := "abc\x00"
err := PrintableText()(&str)
if err == nil {
t.Error("expected validation error")
}
// The null byte is at rune position 3 (after 'a', 'b', 'c')
if !strings.Contains(err.Message, "position 3") {
t.Errorf("expected position 3 in error message, got: %s", err.Message)
}
})
}
func TestSafeText(t *testing.T) {
t.Run("valid text", func(t *testing.T) {
str := "Product Name 2024"
err := SafeText(100)(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid UTF-8 text", func(t *testing.T) {
str := "José García"
err := SafeText(50)(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid text with emoji", func(t *testing.T) {
str := "Hello World 🌍"
err := SafeText(50)(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid text with apostrophe and hyphen", func(t *testing.T) {
str := "O'Brien-Smith"
err := SafeText(50)(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("invalid - empty string", func(t *testing.T) {
str := ""
err := SafeText(100)(&str)
if err == nil {
t.Error("expected validation error for empty string")
}
if !strings.Contains(err.Message, "empty") && !strings.Contains(err.Message, "required") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - exceeds max length", func(t *testing.T) {
str := "This is a very long string that exceeds the maximum length"
err := SafeText(10)(&str)
if err == nil {
t.Error("expected validation error for exceeding max length")
}
if !strings.Contains(err.Message, "at most") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - contains HTML tags", func(t *testing.T) {
str := "Hello <b>World</b>"
err := SafeText(100)(&str)
if err == nil {
t.Error("expected validation error for HTML tags")
}
if !strings.Contains(err.Message, "HTML tags") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - contains script tag", func(t *testing.T) {
str := "<script>alert('xss')</script>"
err := SafeText(100)(&str)
if err == nil {
t.Error("expected validation error for script tag")
}
})
t.Run("invalid - contains angle brackets", func(t *testing.T) {
str := "5 < 10"
err := SafeText(100)(&str)
if err == nil {
t.Error("expected validation error for angle brackets")
}
if !strings.Contains(err.Message, "angle brackets") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - contains null byte", func(t *testing.T) {
str := "test\x00text"
err := SafeText(100)(&str)
if err == nil {
t.Error("expected validation error for null byte")
}
if !strings.Contains(err.Message, "control character") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - contains tab character", func(t *testing.T) {
str := "test\ttext"
err := SafeText(100)(&str)
if err == nil {
t.Error("expected validation error for tab character")
}
})
t.Run("invalid - contains newline", func(t *testing.T) {
str := "test\ntext"
err := SafeText(100)(&str)
if err == nil {
t.Error("expected validation error for newline")
}
})
t.Run("invalid - contains zero-width space", func(t *testing.T) {
str := "test\u200Btext"
err := SafeText(100)(&str)
if err == nil {
t.Error("expected validation error for zero-width space")
}
if !strings.Contains(err.Message, "zero-width") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - contains RLO character", func(t *testing.T) {
str := "test\u202Eexe.txt"
err := SafeText(100)(&str)
if err == nil {
t.Error("expected validation error for RLO character")
}
if !strings.Contains(err.Message, "bidirectional override") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("invalid - contains private use area character", func(t *testing.T) {
str := "test\uE000text"
err := SafeText(100)(&str)
if err == nil {
t.Error("expected validation error for private use area")
}
if !strings.Contains(err.Message, "private use") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("nil pointer", func(t *testing.T) {
var str *string
err := SafeText(100)(str)
if err != nil {
t.Errorf("expected no error for nil pointer, got: %v", err)
}
})
t.Run("not a string", func(t *testing.T) {
num := 123
err := SafeText(100)(&num)
if err == nil {
t.Error("expected validation error for non-string")
}
if !strings.Contains(err.Message, "must be a string") {
t.Errorf("unexpected error message: %s", err.Message)
}
})
t.Run("combined with validator struct", func(t *testing.T) {
v := New()
title := "Product Title 2024"
v.Check(&title, "title", SafeText(100))
if v.HasErrors() {
t.Errorf("expected no errors, got: %v", v.Errors())
}
})
t.Run("combined with validator struct - invalid", func(t *testing.T) {
v := New()
malicious := "<script>alert('xss')</script>"
v.Check(&malicious, "content", SafeText(100))
if !v.HasErrors() {
t.Error("expected validation errors")
}
errors := v.Errors()
found := false
for _, err := range errors {
if strings.Contains(err.Message, "HTML tags") || strings.Contains(err.Message, "angle brackets") {
found = true
break
}
}
if !found {
t.Error("expected error about HTML tags or angle brackets")
}
})
t.Run("edge case - exactly at max length", func(t *testing.T) {
str := "12345"
err := SafeText(5)(&str)
if err != nil {
t.Errorf("expected no error for string at max length, got: %v", err)
}
})
t.Run("edge case - one character over max length", func(t *testing.T) {
str := "123456"
err := SafeText(5)(&str)
if err == nil {
t.Error("expected validation error for string over max length")
}
})
}

View File

@@ -0,0 +1,275 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"fmt"
"reflect"
"regexp"
"strings"
)
var (
alphaNumericRegex = regexp.MustCompile(`^[a-zA-Z0-9]+$`)
slugRegex = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
)
// MinLen validates that a string has at least the specified minimum length.
func MinLen(minLength int) ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
}
if len(str) < minLength {
return newValidationError(
ErrorCodeTooShort,
fmt.Sprintf("must be at least %d characters", minLength),
)
}
return nil
}
}
// MaxLen validates that a string does not exceed the specified maximum length.
func MaxLen(maxLength int) ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
}
if len(str) > maxLength {
return newValidationError(
ErrorCodeTooLong,
fmt.Sprintf("must be at most %d characters", maxLength),
)
}
return nil
}
}
// Pattern validates that a string matches the specified regular expression pattern.
func Pattern(pattern string, message string) ValidatorFunc {
regex := regexp.MustCompile(pattern)
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
}
if !regex.MatchString(str) {
if message == "" {
message = fmt.Sprintf("must match pattern: %s", pattern)
}
return newValidationError(ErrorCodeInvalidFormat, message)
}
return nil
}
}
// AlphaNumeric validates that a string contains only letters and numbers.
func AlphaNumeric() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
}
if str == "" {
return nil
}
if !alphaNumericRegex.MatchString(str) {
return newValidationError(ErrorCodeInvalidFormat, "must contain only letters and numbers")
}
return nil
}
}
// NoSpaces validates that a string does not contain any spaces.
func NoSpaces() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
}
if str == "" {
return nil
}
if strings.Contains(str, " ") {
return newValidationError(ErrorCodeInvalidFormat, "must not contain spaces")
}
return nil
}
}
// Slug validates that a string is a valid URL slug (lowercase letters, numbers, and hyphens).
func Slug() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
str, ok := actualValue.(string)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
}
if str == "" {
return nil
}
if !slugRegex.MatchString(str) {
return newValidationError(ErrorCodeInvalidFormat, "must be a valid slug (lowercase letters, numbers, and hyphens)")
}
return nil
}
}
// OneOfSlice validates that a value is one of the allowed values in the slice.
// Accepts a slice of any type. Compares by value first, then by string representation.
func OneOfSlice[T any](allowed []T) ValidatorFunc {
// Build allowed map with string keys for flexible comparison
allowedMap := make(map[string]bool)
allowedStrings := make([]string, 0, len(allowed))
for _, v := range allowed {
str := fmt.Sprint(v)
allowedMap[str] = true
allowedStrings = append(allowedStrings, str)
}
return func(value any) *ValidationError {
// Handle nil values first
if value == nil {
return nil
}
// Dereference all pointer levels
actualValue := value
val := reflect.ValueOf(value)
for val.Kind() == reflect.Ptr {
if val.IsNil() {
return nil
}
val = val.Elem()
actualValue = val.Interface()
}
// First try exact match with DeepEqual
for _, allowedVal := range allowed {
if reflect.DeepEqual(actualValue, allowedVal) {
return nil
}
}
// Then try string comparison (for custom string types)
valueStr := fmt.Sprint(actualValue)
if allowedMap[valueStr] {
return nil
}
return newValidationError(
ErrorCodeInvalidEnum,
fmt.Sprintf("must be one of: %s", strings.Join(allowedStrings, ", ")),
)
}
}
// OneOf validates that a value is one of the allowed values.
// Accepts strings or types that implement fmt.Stringer as variadic arguments.
func OneOf(allowed ...any) ValidatorFunc {
allowedMap := make(map[string]bool)
allowedStrings := make([]string, 0, len(allowed))
for _, v := range allowed {
var str string
switch val := v.(type) {
case string:
str = val
case fmt.Stringer:
str = val.String()
default:
str = fmt.Sprint(val)
}
allowedMap[str] = true
allowedStrings = append(allowedStrings, str)
}
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
var str string
switch v := actualValue.(type) {
case string:
str = v
default:
if stringer, ok := actualValue.(fmt.Stringer); ok {
str = stringer.String()
} else {
return newValidationError(ErrorCodeInvalidEnum, "value must be a string or implement fmt.Stringer")
}
}
if !allowedMap[str] {
return newValidationError(
ErrorCodeInvalidEnum,
fmt.Sprintf("must be one of: %s", strings.Join(allowedStrings, ", ")),
)
}
return nil
}
}

View File

@@ -0,0 +1,303 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"testing"
"go.gearno.de/x/ref"
)
func TestMinLen(t *testing.T) {
tests := []struct {
name string
value any
minLen int
wantError bool
}{
{"valid string", "hello", 3, false},
{"exact length", "hello", 5, false},
{"too short", "hi", 5, true},
{"nil pointer", (*string)(nil), 5, false}, // Skip validation
{"valid pointer", ref.Ref("hello"), 3, false},
{"non-string", 123, 5, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := MinLen(tt.minLen)(tt.value)
if (err != nil) != tt.wantError {
t.Errorf("MinLen() error = %v, wantError %v", err, tt.wantError)
}
})
}
}
func TestMaxLen(t *testing.T) {
tests := []struct {
name string
value any
maxLen int
wantError bool
}{
{"valid string", "hello", 10, false},
{"exact length", "hello", 5, false},
{"too long", "hello world", 5, true},
{"nil pointer", (*string)(nil), 5, false}, // Skip validation
{"valid pointer", ref.Ref("hi"), 5, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := MaxLen(tt.maxLen)(tt.value)
if (err != nil) != tt.wantError {
t.Errorf("MaxLen() error = %v, wantError %v", err, tt.wantError)
}
})
}
}
func TestPattern(t *testing.T) {
tests := []struct {
name string
value any
pattern string
message string
wantError bool
}{
{"valid pattern", "abc123", `^[a-z0-9]+$`, "", false},
{"invalid pattern", "ABC123", `^[a-z0-9]+$`, "", true},
{"custom message", "invalid", `^valid$`, "must be 'valid'", true},
{"nil pointer", (*string)(nil), `^test$`, "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Pattern(tt.pattern, tt.message)(tt.value)
if (err != nil) != tt.wantError {
t.Errorf("Pattern() error = %v, wantError %v", err, tt.wantError)
}
if err != nil && tt.message != "" && err.Message != tt.message {
t.Errorf("Expected message '%s', got '%s'", tt.message, err.Message)
}
})
}
}
func TestAlphaNumeric(t *testing.T) {
t.Run("valid alphanumeric", func(t *testing.T) {
str := "abc123"
err := AlphaNumeric()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("only letters", func(t *testing.T) {
str := "abcDEF"
err := AlphaNumeric()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("only numbers", func(t *testing.T) {
str := "123456"
err := AlphaNumeric()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("contains spaces", func(t *testing.T) {
str := "abc 123"
err := AlphaNumeric()(&str)
if err == nil {
t.Error("expected validation error")
}
})
t.Run("contains special characters", func(t *testing.T) {
str := "abc-123"
err := AlphaNumeric()(&str)
if err == nil {
t.Error("expected validation error")
}
})
t.Run("empty string", func(t *testing.T) {
str := ""
err := AlphaNumeric()(&str)
if err != nil {
t.Errorf("expected no error for empty string, got: %v", err)
}
})
t.Run("nil pointer", func(t *testing.T) {
var str *string
err := AlphaNumeric()(str)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
}
func TestNoSpaces(t *testing.T) {
t.Run("no spaces", func(t *testing.T) {
str := "hello-world"
err := NoSpaces()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("contains spaces", func(t *testing.T) {
str := "hello world"
err := NoSpaces()(&str)
if err == nil {
t.Error("expected validation error")
}
})
t.Run("multiple spaces", func(t *testing.T) {
str := "hello world test"
err := NoSpaces()(&str)
if err == nil {
t.Error("expected validation error")
}
})
t.Run("empty string", func(t *testing.T) {
str := ""
err := NoSpaces()(&str)
if err != nil {
t.Errorf("expected no error for empty string, got: %v", err)
}
})
t.Run("nil pointer", func(t *testing.T) {
var str *string
err := NoSpaces()(str)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
}
func TestSlug(t *testing.T) {
t.Run("valid slug", func(t *testing.T) {
str := "hello-world"
err := Slug()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("valid slug with numbers", func(t *testing.T) {
str := "hello-world-123"
err := Slug()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("single word", func(t *testing.T) {
str := "hello"
err := Slug()(&str)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("contains uppercase", func(t *testing.T) {
str := "Hello-World"
err := Slug()(&str)
if err == nil {
t.Error("expected validation error for uppercase")
}
})
t.Run("contains spaces", func(t *testing.T) {
str := "hello world"
err := Slug()(&str)
if err == nil {
t.Error("expected validation error for spaces")
}
})
t.Run("contains underscores", func(t *testing.T) {
str := "hello_world"
err := Slug()(&str)
if err == nil {
t.Error("expected validation error for underscores")
}
})
t.Run("starts with hyphen", func(t *testing.T) {
str := "-hello"
err := Slug()(&str)
if err == nil {
t.Error("expected validation error for leading hyphen")
}
})
t.Run("ends with hyphen", func(t *testing.T) {
str := "hello-"
err := Slug()(&str)
if err == nil {
t.Error("expected validation error for trailing hyphen")
}
})
t.Run("empty string", func(t *testing.T) {
str := ""
err := Slug()(&str)
if err != nil {
t.Errorf("expected no error for empty string, got: %v", err)
}
})
t.Run("nil pointer", func(t *testing.T) {
var str *string
err := Slug()(str)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
}
func TestOneOf(t *testing.T) {
tests := []struct {
name string
value any
allowed []string
wantError bool
}{
{"valid value", "apple", []string{"apple", "banana", "orange"}, false},
{"invalid value", "grape", []string{"apple", "banana", "orange"}, true},
{"nil pointer", (*string)(nil), []string{"apple"}, false},
{"empty string", "", []string{"apple", ""}, false},
{"non-string", 123, []string{"apple"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := OneOfSlice(tt.allowed)(tt.value)
if (err != nil) != tt.wantError {
t.Errorf("OneOfSlice() error = %v, wantError %v", err, tt.wantError)
}
})
}
}

View File

@@ -0,0 +1,208 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"fmt"
"time"
)
// After validates that a time is after the specified reference time.
// The reference time can be either time.Time or *time.Time.
func After(t any) ValidatorFunc {
return func(value any) *ValidationError {
// Extract the reference time
refValue, refIsNil := dereferenceValue(t)
if refIsNil {
return nil // No reference time to compare against
}
refTime, ok := refValue.(time.Time)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "reference time must be time.Time")
}
// Extract the value being validated
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
timeVal, ok := actualValue.(time.Time)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Time")
}
if !timeVal.After(refTime) {
return newValidationError(
ErrorCodeOutOfRange,
fmt.Sprintf("must be after %s", refTime.Format(time.RFC3339)),
)
}
return nil
}
}
// Before validates that a time is before the specified reference time.
// The reference time can be either time.Time or *time.Time.
func Before(t any) ValidatorFunc {
return func(value any) *ValidationError {
// Extract the reference time
refValue, refIsNil := dereferenceValue(t)
if refIsNil {
return nil // No reference time to compare against
}
refTime, ok := refValue.(time.Time)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "reference time must be time.Time")
}
// Extract the value being validated
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
timeVal, ok := actualValue.(time.Time)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Time")
}
if !timeVal.Before(refTime) {
return newValidationError(
ErrorCodeOutOfRange,
fmt.Sprintf("must be before %s", refTime.Format(time.RFC3339)),
)
}
return nil
}
}
// FutureDate validates that a time is in the future.
func FutureDate() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
timeVal, ok := actualValue.(time.Time)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Time")
}
if !timeVal.After(time.Now()) {
return newValidationError(ErrorCodeOutOfRange, "must be a future date")
}
return nil
}
}
// PastDate validates that a time is in the past.
func PastDate() ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
timeVal, ok := actualValue.(time.Time)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Time")
}
if !timeVal.Before(time.Now()) {
return newValidationError(ErrorCodeOutOfRange, "must be a past date")
}
return nil
}
}
// MinDuration validates that a duration is at least the specified minimum value.
func MinDuration(min time.Duration) ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
duration, ok := actualValue.(time.Duration)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Duration")
}
if duration < min {
return newValidationError(
ErrorCodeOutOfRange,
fmt.Sprintf("must be at least %s", min),
)
}
return nil
}
}
// MaxDuration validates that a duration does not exceed the specified maximum value.
func MaxDuration(max time.Duration) ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
duration, ok := actualValue.(time.Duration)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Duration")
}
if duration > max {
return newValidationError(
ErrorCodeOutOfRange,
fmt.Sprintf("must be at most %s", max),
)
}
return nil
}
}
// RangeDuration validates that a duration is within the specified range (inclusive).
func RangeDuration(min, max time.Duration) ValidatorFunc {
return func(value any) *ValidationError {
actualValue, isNil := dereferenceValue(value)
if isNil {
return nil
}
duration, ok := actualValue.(time.Duration)
if !ok {
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Duration")
}
if duration < min || duration > max {
return newValidationError(
ErrorCodeOutOfRange,
fmt.Sprintf("must be between %s and %s", min, max),
)
}
return nil
}
}

View File

@@ -0,0 +1,293 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package validator
import (
"testing"
"time"
)
func TestAfter(t *testing.T) {
now := time.Now()
past := now.Add(-24 * time.Hour)
future := now.Add(24 * time.Hour)
t.Run("time after reference", func(t *testing.T) {
err := After(past)(&future)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("time before reference", func(t *testing.T) {
err := After(future)(&past)
if err == nil {
t.Fatal("expected validation error")
}
if err.Code != ErrorCodeOutOfRange {
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
}
})
t.Run("same time", func(t *testing.T) {
err := After(now)(&now)
if err == nil {
t.Error("expected validation error for equal times")
}
})
t.Run("nil pointer", func(t *testing.T) {
var timeVal *time.Time
err := After(now)(timeVal)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
}
func TestBefore(t *testing.T) {
now := time.Now()
past := now.Add(-24 * time.Hour)
future := now.Add(24 * time.Hour)
t.Run("time before reference", func(t *testing.T) {
err := Before(future)(&past)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("time after reference", func(t *testing.T) {
err := Before(past)(&future)
if err == nil {
t.Fatal("expected validation error")
}
if err.Code != ErrorCodeOutOfRange {
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
}
})
t.Run("same time", func(t *testing.T) {
err := Before(now)(&now)
if err == nil {
t.Error("expected validation error for equal times")
}
})
t.Run("nil pointer", func(t *testing.T) {
var timeVal *time.Time
err := Before(now)(timeVal)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
}
func TestFutureDate(t *testing.T) {
future := time.Now().Add(24 * time.Hour)
past := time.Now().Add(-24 * time.Hour)
t.Run("future date", func(t *testing.T) {
err := FutureDate()(&future)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("past date", func(t *testing.T) {
err := FutureDate()(&past)
if err == nil {
t.Fatal("expected validation error")
}
if err.Code != ErrorCodeOutOfRange {
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
}
})
t.Run("nil pointer", func(t *testing.T) {
var timeVal *time.Time
err := FutureDate()(timeVal)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
}
func TestPastDate(t *testing.T) {
future := time.Now().Add(24 * time.Hour)
past := time.Now().Add(-24 * time.Hour)
t.Run("past date", func(t *testing.T) {
err := PastDate()(&past)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("future date", func(t *testing.T) {
err := PastDate()(&future)
if err == nil {
t.Fatal("expected validation error")
}
if err.Code != ErrorCodeOutOfRange {
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
}
})
t.Run("nil pointer", func(t *testing.T) {
var timeVal *time.Time
err := PastDate()(timeVal)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
}
func TestMinDuration(t *testing.T) {
minDuration := 10 * time.Minute
t.Run("duration above minimum", func(t *testing.T) {
duration := 20 * time.Minute
err := MinDuration(minDuration)(&duration)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("duration equal to minimum", func(t *testing.T) {
duration := 10 * time.Minute
err := MinDuration(minDuration)(&duration)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("duration below minimum", func(t *testing.T) {
duration := 5 * time.Minute
err := MinDuration(minDuration)(&duration)
if err == nil {
t.Fatal("expected validation error")
}
if err.Code != ErrorCodeOutOfRange {
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
}
})
t.Run("nil pointer", func(t *testing.T) {
var duration *time.Duration
err := MinDuration(minDuration)(duration)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
}
func TestMaxDuration(t *testing.T) {
maxDuration := 1 * time.Hour
t.Run("duration below maximum", func(t *testing.T) {
duration := 30 * time.Minute
err := MaxDuration(maxDuration)(&duration)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("duration equal to maximum", func(t *testing.T) {
duration := 1 * time.Hour
err := MaxDuration(maxDuration)(&duration)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("duration above maximum", func(t *testing.T) {
duration := 2 * time.Hour
err := MaxDuration(maxDuration)(&duration)
if err == nil {
t.Fatal("expected validation error")
}
if err.Code != ErrorCodeOutOfRange {
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
}
})
t.Run("nil pointer", func(t *testing.T) {
var duration *time.Duration
err := MaxDuration(maxDuration)(duration)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
}
func TestRangeDuration(t *testing.T) {
minDuration := 10 * time.Minute
maxDuration := 1 * time.Hour
t.Run("duration within range", func(t *testing.T) {
duration := 30 * time.Minute
err := RangeDuration(minDuration, maxDuration)(&duration)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("duration at minimum", func(t *testing.T) {
duration := 10 * time.Minute
err := RangeDuration(minDuration, maxDuration)(&duration)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("duration at maximum", func(t *testing.T) {
duration := 1 * time.Hour
err := RangeDuration(minDuration, maxDuration)(&duration)
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
})
t.Run("duration below minimum", func(t *testing.T) {
duration := 5 * time.Minute
err := RangeDuration(minDuration, maxDuration)(&duration)
if err == nil {
t.Fatal("expected validation error")
}
if err.Code != ErrorCodeOutOfRange {
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
}
})
t.Run("duration above maximum", func(t *testing.T) {
duration := 2 * time.Hour
err := RangeDuration(minDuration, maxDuration)(&duration)
if err == nil {
t.Fatal("expected validation error")
}
if err.Code != ErrorCodeOutOfRange {
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
}
})
t.Run("nil pointer", func(t *testing.T) {
var duration *time.Duration
err := RangeDuration(minDuration, maxDuration)(duration)
if err != nil {
t.Errorf("expected no error for nil, got: %v", err)
}
})
}