Form Validators

Overview

Form Validators are responsible for validating user input data submitted through forms. They ensure data integrity, enforce business rules, and provide structured error feedback before data reaches the business logic layer.

Purpose

Form Validators serve several key functions: - Input Validation - Verify form data meets required criteria - Error Collection - Gather and structure validation errors - Business Rule Enforcement - Apply domain-specific validation rules - Data Integrity - Ensure data consistency before processing

Structure

Each form validator typically follows this pattern:

type CreatePlantFormValidator struct {
    Validator *validator.Validator
}

func (this *CreatePlantFormValidator) Validate(form *forms.CreatePlantForm) (*formLib.FormBag, error) {
    bag := this.Validator.MakeFormBag()

    // Perform validations
    this.validateName(form, bag)
    this.validateSpecies(form, bag)
    this.validateSize(form, bag)

    if !this.Validator.IsValid(bag) {
        return bag, this.Validator.NewInvalidFormError()
    }

    return bag, nil
}

Common Validation Patterns

Required Field Validation

func (this *CreatePlantFormValidator) validateName(form *forms.CreatePlantForm, bag *formLib.FormBag) {
    if strings.TrimSpace(form.Name) == "" {
        this.Validator.AddError(bag, "name", "Name is required")
    }
}

String Length Validation

func (this *CreatePlantFormValidator) validateDescription(form *forms.CreatePlantForm, bag *formLib.FormBag) {
    desc := strings.TrimSpace(form.Description)

    if len(desc) > 500 {
        this.Validator.AddError(bag, "description", "Description cannot exceed 500 characters")
    }

    if len(desc) < 10 {
        this.Validator.AddError(bag, "description", "Description must be at least 10 characters")
    }
}

Format Validation

func (this *CreateUserFormValidator) validateEmail(form *forms.CreateUserForm, bag *formLib.FormBag) {
    email := strings.TrimSpace(form.Email)

    if email == "" {
        this.Validator.AddError(bag, "email", "Email is required")
        return
    }

    emailRegex := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
    if matched, _ := regexp.MatchString(emailRegex, email); !matched {
        this.Validator.AddError(bag, "email", "Invalid email format")
    }
}

Numeric Range Validation

func (this *CreatePlantFormValidator) validateSize(form *forms.CreatePlantForm, bag *formLib.FormBag) {
    if form.Size < 0 {
        this.Validator.AddError(bag, "size", "Size cannot be negative")
    }

    if form.Size > 1000 {
        this.Validator.AddError(bag, "size", "Size cannot exceed 1000")
    }
}

Boolean Validation

func (this *CreatePlantFormValidator) validatePerennial(form *forms.CreatePlantForm, bag *formLib.FormBag) {
    // Boolean validation is typically handled by form binding
    // Custom logic can be added for business rules
    if form.Perennial && form.Size < 20 {
        this.Validator.AddError(bag, "perennial", "Perennial plants must be at least 20cm")
    }
}

Advanced Validation Patterns

Cross-Field Validation

func (this *CreatePlantFormValidator) validatePlantingDates(form *forms.CreatePlantForm, bag *formLib.FormBag) {
    if !form.PlantedAt.IsZero() && !form.HarvestedAt.IsZero() {
        if form.HarvestedAt.Before(form.PlantedAt) {
            this.Validator.AddError(bag, "harvested_at", "Harvest date cannot be before planting date")
        }
    }
}

Conditional Validation

func (this *CreatePlantFormValidator) validateHarvestData(form *forms.CreatePlantForm, bag *formLib.FormBag) {
    if form.Edible {
        if form.HarvestSeason == "" {
            this.Validator.AddError(bag, "harvest_season", "Harvest season is required for edible plants")
        }

        if form.DaysToHarvest <= 0 {
            this.Validator.AddError(bag, "days_to_harvest", "Days to harvest must be specified for edible plants")
        }
    }
}

Database Validation (with dependencies)

type CreatePlantFormValidator struct {
    Validator    *validator.Validator
    PlantFetcher *fetchers.PlantFetcher
}

func (this *CreatePlantFormValidator) validateUniqueName(ctx context.Context, form *forms.CreatePlantForm, bag *formLib.FormBag) {
    if form.Name == "" {
        return // Skip if name is empty (handled by required validation)
    }

    existing, exists, err := this.PlantFetcher.FindOneByName(ctx, form.Name)
    if err != nil {
        this.Validator.AddError(bag, "name", "Unable to validate plant name")
        return
    }

    if exists {
        this.Validator.AddError(bag, "name", "Plant name already exists")
    }
}

Integration with Controllers

Form validators are typically used in controller methods:

func (gc *GardenController) Create(c *gin.Context) {
    form := &forms.CreateGardenForm{}

    // Bind form data
    if err := c.ShouldBind(form); err != nil {
        c.JSON(400, gin.H{"error": "Invalid form data"})
        return
    }

    // Validate form
    bag, err := gc.CreateGardenFormValidator.Validate(form)
    if err != nil {
        c.JSON(400, gin.H{
            "error": "Validation failed",
            "errors": bag.Errors,
        })
        return
    }

    // Proceed with business logic...
}

Error Handling

Error Structure

type FormBag struct {
    Errors map[string][]string
    Valid  bool
}

// Add error to specific field
func (v *Validator) AddError(bag *FormBag, field, message string) {
    if bag.Errors == nil {
        bag.Errors = make(map[string][]string)
    }
    bag.Errors[field] = append(bag.Errors[field], message)
    bag.Valid = false
}

Multiple Errors per Field

func (this *CreateUserFormValidator) validatePassword(form *forms.CreateUserForm, bag *formLib.FormBag) {
    password := form.Password

    if len(password) < 8 {
        this.Validator.AddError(bag, "password", "Password must be at least 8 characters")
    }

    if !strings.ContainsAny(password, "0123456789") {
        this.Validator.AddError(bag, "password", "Password must contain at least one number")
    }

    if !strings.ContainsAny(password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
        this.Validator.AddError(bag, "password", "Password must contain at least one uppercase letter")
    }
}

Custom Validation Rules

Reusable Validation Functions

func (v *Validator) ValidateRequiredString(bag *formLib.FormBag, field, value, message string) {
    if strings.TrimSpace(value) == "" {
        v.AddError(bag, field, message)
    }
}

func (v *Validator) ValidateStringLength(bag *formLib.FormBag, field, value string, min, max int) {
    length := len(strings.TrimSpace(value))

    if length < min {
        v.AddError(bag, field, fmt.Sprintf("Must be at least %d characters", min))
    }

    if length > max {
        v.AddError(bag, field, fmt.Sprintf("Cannot exceed %d characters", max))
    }
}

Using Custom Rules

func (this *CreatePlantFormValidator) Validate(form *forms.CreatePlantForm) (*formLib.FormBag, error) {
    bag := this.Validator.MakeFormBag()

    this.Validator.ValidateRequiredString(bag, "name", form.Name, "Plant name is required")
    this.Validator.ValidateStringLength(bag, "name", form.Name, 2, 100)
    this.Validator.ValidateStringLength(bag, "description", form.Description, 0, 500)

    if !this.Validator.IsValid(bag) {
        return bag, this.Validator.NewInvalidFormError()
    }

    return bag, nil
}

Testing Form Validators

func TestCreatePlantFormValidator_ValidForm(t *testing.T) {
    validator := setupCreatePlantFormValidator()

    form := &forms.CreatePlantForm{
        Name:        "Tomato",
        Species:     "Solanum lycopersicum",
        Size:        50,
        Perennial:   false,
    }

    bag, err := validator.Validate(form)

    assert.NoError(t, err)
    assert.True(t, bag.Valid)
    assert.Empty(t, bag.Errors)
}

func TestCreatePlantFormValidator_InvalidForm(t *testing.T) {
    validator := setupCreatePlantFormValidator()

    form := &forms.CreatePlantForm{
        Name:    "", // Invalid - required
        Size:    -5, // Invalid - negative
    }

    bag, err := validator.Validate(form)

    assert.Error(t, err)
    assert.False(t, bag.Valid)
    assert.Contains(t, bag.Errors, "name")
    assert.Contains(t, bag.Errors, "size")
}

Best Practices

Validation Order

  • Validate required fields first
  • Perform format validation before business rules
  • Check database constraints last

Error Messages

  • Provide clear, actionable error messages
  • Use consistent language across validators
  • Avoid exposing internal implementation details

Performance

  • Validate cheapest rules first (fail fast)
  • Avoid expensive database calls when possible
  • Cache validation results when appropriate

Maintainability

  • Use consistent validation patterns
  • Extract common validation logic into reusable functions
  • Keep validators focused on single forms

Form Validators ensure data quality and provide user-friendly error feedback, forming a crucial layer in the application’s data validation strategy.