Introduce pkg/mail.Addr

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-12-12 15:32:46 +01:00
parent cab9d6f2ed
commit c0b2a5702d
85 changed files with 842 additions and 646 deletions

View File

@@ -25,7 +25,7 @@ func BenchmarkValidate_SingleField(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
v := New()
v.Check(&email, "email", Required(), Email())
v.Check(&email, "email", Required(), NotEmpty())
}
}
@@ -37,7 +37,7 @@ func BenchmarkValidate_MultipleFields(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
v := New()
v.Check(&email, "email", Required(), Email())
v.Check(&email, "email", Required(), NotEmpty())
v.Check(&password, "password", Required(), MinLen(8))
v.Check(&age, "age", Min(18), Max(120))
}
@@ -108,16 +108,6 @@ func BenchmarkValidate_ArrayValidation(b *testing.B) {
}
}
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()
@@ -214,7 +204,7 @@ func BenchmarkValidate_WithErrors(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
v := New()
v.Check(&email, "email", Required(), Email())
v.Check(&email, "email", Required(), NotEmpty())
if !v.HasErrors() {
b.Fatal("expected validation error")
}
@@ -256,7 +246,7 @@ func BenchmarkValidate_ComplexForm(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
v := New()
v.Check(&user.Email, "email", Required(), Email())
v.Check(&user.Email, "email", Required(), NotEmpty())
v.Check(&user.Name, "name", Required(), MinLen(2))
v.Check(&user.Age, "age", Min(18), Max(120))
v.Check(user.Website, "website", URL())

View File

@@ -19,13 +19,15 @@ import (
"testing"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/mail"
)
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())
email := mail.Addr("test@example.com")
v.Check(&email, "email", Required(), NotEmpty())
if v.HasErrors() {
t.Errorf("expected no errors, got: %v", v.Errors())
@@ -34,10 +36,10 @@ func TestValidator_Validate(t *testing.T) {
t.Run("multiple field validations", func(t *testing.T) {
v := New()
email := ""
email := mail.Nil
password := "123"
v.Check(&email, "email", Required(), Email())
v.Check(email, "email", NotEmpty())
v.Check(&password, "password", Required(), MinLen(8))
if !v.HasErrors() {
@@ -69,8 +71,8 @@ func TestValidator_CheckNested(t *testing.T) {
v := New()
v.CheckNested("user", func(nv *Validator) {
email := "invalid"
nv.Check(&email, "email", Email())
email := mail.Nil
nv.Check(&email, "email", NotEmpty())
nv.CheckNested("address", func(av *Validator) {
city := ""
@@ -186,7 +188,7 @@ func TestOptionalFieldExample(t *testing.T) {
v := New()
v.Check(&req.Email, "email", Required(), Email())
v.Check(&req.Email, "email", Required(), NotEmpty())
v.Check(&req.Name, "name", Required(), MinLen(2))
v.Check(req.Website, "website", URL())
v.Check(req.PhoneNumber, "phoneNumber", MinLen(10))
@@ -232,7 +234,7 @@ func TestRealWorldExample(t *testing.T) {
}
user := User{
Email: "invalid-email",
Email: "",
Password: "123",
Age: 15,
Website: ref.Ref("not-a-url"),
@@ -245,7 +247,7 @@ func TestRealWorldExample(t *testing.T) {
v := New()
// Validate user fields
v.Check(&user.Email, "email", Required(), Email())
v.Check(&user.Email, "email", Required(), NotEmpty())
v.Check(&user.Password, "password", Required(), MinLen(8))
v.Check(&user.Age, "age", Min(18), Max(120))
v.Check(user.Website, "website", URL())
@@ -262,7 +264,7 @@ func TestRealWorldExample(t *testing.T) {
errors := v.Errors()
expectedErrors := map[string]ErrorCode{
"email": ErrorCodeInvalidEmail,
"email": ErrorCodeRequired,
"password": ErrorCodeTooShort,
"age": ErrorCodeOutOfRange,
"website": ErrorCodeInvalidURL,
@@ -373,10 +375,10 @@ func TestDuplicateValidators(t *testing.T) {
}
})
t.Run("duplicate Email creates two errors", func(t *testing.T) {
t.Run("duplicate NotEmpty creates two errors", func(t *testing.T) {
v := New()
email := "invalid"
v.Check(&email, "email", Email(), Email())
email := mail.Nil
v.Check(&email, "email", NotEmpty(), NotEmpty())
errors := v.Errors()
if len(errors) != 2 {
@@ -405,22 +407,23 @@ func TestDuplicateValidators(t *testing.T) {
func TestStandardErrorPattern(t *testing.T) {
// Simulates a typical validation function
validateUser := func(email, password string) error {
validateUser := func(email mail.Addr, password string) error {
v := New()
v.Check(&email, "email", Required(), Email())
v.Check(&email, "email", Required(), NotEmpty())
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 {
email := mail.Addr("user@example.com")
if err := validateUser(email, "password123"); err != nil {
t.Errorf("expected nil, got: %v", err)
}
})
t.Run("invalid data returns ValidationErrors as error", func(t *testing.T) {
err := validateUser("", "123")
err := validateUser(mail.Nil, "123")
if err == nil {
t.Fatal("expected validation errors")
}

View File

@@ -17,6 +17,8 @@ package validator
import (
"reflect"
"strings"
"go.probo.inc/probo/pkg/mail"
)
// Required validates that a field has a value.
@@ -59,6 +61,10 @@ func NotEmpty() ValidatorFunc {
if strings.TrimSpace(v) == "" {
return newValidationError(ErrorCodeRequired, "field cannot be empty")
}
case mail.Addr:
if v == mail.Nil {
return newValidationError(ErrorCodeRequired, "field cannot be empty")
}
default:
rv := reflect.ValueOf(actualValue)
if rv.Kind() == reflect.Slice && rv.Len() == 0 {

View File

@@ -23,36 +23,10 @@ import (
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 {

View File

@@ -21,35 +21,6 @@ import (
"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