Frontend Labs
⋯

Day 77 — Input Validation

Learning Objectives

  • Build a struct tag validation framework using reflect
  • Return ALL validation errors (not just the first) — critical for UX
  • Enforce field-level constraints as a security layer

Key Concepts

Validation vs Sanitization:
  Validation: check if input is acceptable → reject if not (CORRECT)
  Sanitization: modify input to make it acceptable → risky (SQL injection sanitization often fails)

Allowlist vs Denylist:
  Allowlist: only accept known-good patterns → CORRECT
  Denylist: reject known-bad patterns → always incomplete
  Rule: always use allowlist validation

Tags: validate:"required,min=1,max=255,pattern=^[a-zA-Z0-9_]+$"

Exercise: Struct Tag Validator

package validation

import (
    "fmt"
    "reflect"
    "regexp"
    "strconv"
    "strings"
)

type ValidationError struct {
    Field   string
    Message string
}

func (e ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

type ValidationErrors []ValidationError

func (ve ValidationErrors) Error() string {
    msgs := make([]string, len(ve))
    for i, e := range ve {
        msgs[i] = e.Error()
    }
    return strings.Join(msgs, "; ")
}

// Validate validates a struct using `validate` field tags
// Tags: required, min=N, max=N, pattern=regex, email, oneof=a|b|c
func Validate(v interface{}) ValidationErrors {
    val := reflect.ValueOf(v)
    if val.Kind() == reflect.Ptr {
        val = val.Elem()
    }
    if val.Kind() != reflect.Struct {
        return nil
    }

    var errs ValidationErrors
    typ := val.Type()

    for i := 0; i < val.NumField(); i++ {
        field := typ.Field(i)
        fieldVal := val.Field(i)
        tag := field.Tag.Get("validate")
        if tag == "" { continue }

        jsonName := field.Tag.Get("json")
        if jsonName == "" { jsonName = field.Name }
        jsonName = strings.SplitN(jsonName, ",", 2)[0]

        errs = append(errs, validateField(jsonName, fieldVal, tag)...)
    }

    return errs
}

func validateField(name string, v reflect.Value, tag string) []ValidationError {
    var errs []ValidationError
    rules := strings.Split(tag, ",")

    strVal := ""
    if v.Kind() == reflect.String {
        strVal = v.String()
    }

    for _, rule := range rules {
        rule = strings.TrimSpace(rule)
        switch {
        case rule == "required":
            if strVal == "" {
                errs = append(errs, ValidationError{name, "required field is empty"})
            }

        case strings.HasPrefix(rule, "min="):
            n, _ := strconv.Atoi(rule[4:])
            if len(strVal) < n {
                errs = append(errs, ValidationError{name, fmt.Sprintf("minimum length %d, got %d", n, len(strVal))})
            }

        case strings.HasPrefix(rule, "max="):
            n, _ := strconv.Atoi(rule[4:])
            if len(strVal) > n {
                errs = append(errs, ValidationError{name, fmt.Sprintf("maximum length %d, got %d", n, len(strVal))})
            }

        case strings.HasPrefix(rule, "pattern="):
            pattern := rule[8:]
            re, err := regexp.Compile("^(?:" + pattern + ")$")
            if err != nil { continue }
            if !re.MatchString(strVal) {
                errs = append(errs, ValidationError{name, fmt.Sprintf("invalid format (expected pattern %s)", pattern)})
            }

        case rule == "email":
            emailRe := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
            if !emailRe.MatchString(strVal) {
                errs = append(errs, ValidationError{name, "invalid email address"})
            }

        case strings.HasPrefix(rule, "oneof="):
            options := strings.Split(rule[6:], "|")
            found := false
            for _, opt := range options {
                if strVal == opt { found = true; break }
            }
            if !found {
                errs = append(errs, ValidationError{name, fmt.Sprintf("must be one of: %s", strings.Join(options, ", "))})
            }
        }
    }
    return errs
}

// Example usage:
type CreateUserRequest struct {
    Username string `json:"username" validate:"required,min=3,max=64,pattern=[a-zA-Z0-9_]+"`
    Email    string `json:"email"    validate:"required,email"`
    Role     string `json:"role"     validate:"required,oneof=viewer|editor|admin"`
    Password string `json:"password" validate:"required,min=8,max=128"`
}

func ValidateHandler(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid JSON", 400)
        return
    }

    if errs := Validate(req); len(errs) > 0 {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusUnprocessableEntity)
        json.NewEncoder(w).Encode(map[string]any{
            "error":  "validation failed",
            "fields": errs,
        })
        return
    }
    // ... handle valid request
}

Real-World Context

Input validation is OWASP A03 (Injection prevention). Every field that reaches a database, file system, or external API must be validated. The allowlist pattern ([a-zA-Z0-9_]+) is safer than a denylist ([^<>'";&]). Returning all errors at once (not just first) reduces round-trips for API consumers.


Generated from week11.md · 8 Oct 2026 16:36