This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Request Validation

Flexible, multi-strategy validation for Go structs. Supports struct tags, JSON Schema, and custom interfaces

The Rivaas Validation package provides flexible, multi-strategy validation for Go structs. Supports struct tags, JSON Schema, and custom interfaces. Includes detailed error messages and built-in security features.

Features

  • Multiple Validation Strategies
    • Struct tags via go-playground/validator
    • JSON Schema (RFC-compliant)
    • Custom interfaces (Validate() / ValidateContext())
  • Partial Validation - For PATCH requests where only provided fields should be validated
  • Thread-Safe - Safe for concurrent use by multiple goroutines
  • Security - Built-in protections against deep nesting, memory exhaustion, and sensitive data exposure
  • Standalone - Can be used independently without the full Rivaas framework
  • Custom Validators - Easy registration of custom validation tags

Quick Start

Basic Validation

The simplest way to use this package is with the package-level Validate function:

import "rivaas.dev/validation"

type User struct {
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age" validate:"min=18"`
}

user := User{Email: "invalid", Age: 15}
if err := validation.Validate(ctx, &user); err != nil {
    var verr *validation.Error
    if errors.As(err, &verr) {
        for _, fieldErr := range verr.Fields {
            fmt.Printf("%s: %s\n", fieldErr.Path, fieldErr.Message)
        }
    }
}

Custom Validator Instance

For more control, create a Validator instance with custom options:

validator := validation.MustNew(
    validation.WithRedactor(sensitiveFieldRedactor),
    validation.WithMaxErrors(10),
    validation.WithCustomTag("phone", phoneValidator),
)

if err := validator.Validate(ctx, &user); err != nil {
    // Handle validation errors
}

Partial Validation (PATCH Requests)

For PATCH requests where only provided fields should be validated:

// Compute which fields are present in the JSON
presence, _ := validation.ComputePresence(rawJSON)

// Validate only the present fields
err := validator.ValidatePartial(ctx, &user, presence)

Learning Path

Follow these guides to master validation with Rivaas:

  1. Installation - Get started with the validation package
  2. Basic Usage - Learn the fundamentals of validation
  3. Struct Tags - Use go-playground/validator struct tags
  4. JSON Schema - Validate with JSON Schema
  5. Custom Interfaces - Implement Validate() methods
  6. Partial Validation - Handle PATCH requests correctly
  7. Error Handling - Work with structured errors
  8. Custom Validators - Register custom tags and functions
  9. Security - Protect sensitive data and prevent attacks
  10. Examples - Real-world integration patterns

Validation Strategies

The package supports three validation strategies that can be used individually or combined:

1. Struct Tags (go-playground/validator)

Use struct tags with go-playground/validator syntax:

type User struct {
    Email string `validate:"required,email"`
    Age   int    `validate:"min=18,max=120"`
    Name  string `validate:"required,min=2,max=100"`
}

2. JSON Schema

Implement the JSONSchemaProvider interface:

type User struct {
    Email string `json:"email"`
    Age   int    `json:"age"`
}

func (u User) JSONSchema() (id, schema string) {
    return "user-schema", `{
        "type": "object",
        "properties": {
            "email": {"type": "string", "format": "email"},
            "age": {"type": "integer", "minimum": 18}
        },
        "required": ["email"]
    }`
}

3. Custom Validation Interface

Implement ValidatorInterface for simple validation:

type User struct {
    Email string
}

func (u *User) Validate() error {
    if !strings.Contains(u.Email, "@") {
        return errors.New("email must contain @")
    }
    return nil
}

// validation.Validate will automatically call u.Validate()
err := validation.Validate(ctx, &user)

Or implement ValidatorWithContext for context-aware validation:

func (u *User) ValidateContext(ctx context.Context) error {
    // Access request-scoped data from context
    tenant := ctx.Value("tenant").(string)
    // Apply tenant-specific validation rules
    return nil
}

Strategy Priority

The package automatically selects the best strategy based on the type:

Priority Order:

  1. Interface methods (Validate() / ValidateContext())
  2. Struct tags (validate:"...")
  3. JSON Schema (JSONSchemaProvider)

You can explicitly choose a strategy:

err := validator.Validate(ctx, &user, validation.WithStrategy(validation.StrategyTags))

Or run all applicable strategies:

err := validator.Validate(ctx, &user, validation.WithRunAll(true))

Comparison with Other Libraries

Featurerivaas.dev/validationgo-playground/validatorJSON Schema validators
Struct tags
JSON Schema
Custom interfaces
Partial validation
Multi-strategy
Context supportVaries
Built-in redaction
Thread-safeVaries

Next Steps

  • Start with Installation to set up the validation package
  • Explore the API Reference for complete technical details
  • Check out examples for real-world integration patterns

For integration with rivaas/app, the Context provides convenient methods that handle validation automatically.

1 - Installation

Install and set up the validation package

Get started with the Rivaas validation package by installing it in your Go project.

Requirements

  • Go 1.25 or later
  • A Go module-enabled project (with go.mod)

Installation

Install the validation package using go get:

go get rivaas.dev/validation

This will add the package to your go.mod file and download all necessary dependencies.

Dependencies

The validation package depends on:

All dependencies are managed automatically by Go modules.

Verify Installation

Create a simple test file to verify the installation:

package main

import (
    "context"
    "fmt"
    "rivaas.dev/validation"
)

type User struct {
    Email string `validate:"required,email"`
    Age   int    `validate:"min=18"`
}

func main() {
    ctx := context.Background()
    user := User{Email: "test@example.com", Age: 25}
    
    if err := validation.Validate(ctx, &user); err != nil {
        fmt.Println("Validation failed:", err)
        return
    }
    
    fmt.Println("Validation passed!")
}

Run the test:

go run main.go
# Output: Validation passed!

Import Paths

The validation package uses a simple import path:

import "rivaas.dev/validation"

There are no sub-packages to import - all functionality is in the main package.

Version Management

The validation package follows semantic versioning. To use a specific version:

# Install latest version
go get rivaas.dev/validation@latest

# Install specific version
go get rivaas.dev/validation@v1.2.3

# Install specific commit
go get rivaas.dev/validation@abc123

Upgrading

To upgrade to the latest version:

go get -u rivaas.dev/validation

To upgrade all dependencies:

go get -u ./...

Workspace Setup

If using Go workspaces, ensure the validation module is in your workspace:

# Add to workspace
go work use /path/to/rivaas/validation

# Verify workspace
go work sync

Next Steps

Now that the package is installed, learn how to use it:

Troubleshooting

Cannot find module

If you see:

go: finding module for package rivaas.dev/validation

Ensure you have a valid go.mod file and run:

go mod tidy

Version conflicts

If you encounter version conflicts with dependencies:

# Update go.mod
go mod tidy

# Verify dependencies
go mod verify

Build errors

If you encounter build errors after installation:

# Clean module cache
go clean -modcache

# Re-download dependencies
go mod download

For more help, see the Troubleshooting reference.

2 - Basic Usage

Learn the fundamentals of validating structs

Learn how to validate structs using the validation package. This guide starts from simple package-level functions and progresses to customized validator instances.

Package-Level Validation

The simplest way to validate is using the package-level Validate function:

import (
    "context"
    "rivaas.dev/validation"
)

type CreateUserRequest struct {
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age" validate:"min=18"`
}

func Handler(ctx context.Context, req CreateUserRequest) error {
    if err := validation.Validate(ctx, &req); err != nil {
        return err
    }
    // Process valid request
    return nil
}

Handling Validation Errors

Validation errors are returned as structured *validation.Error values:

err := validation.Validate(ctx, &req)
if err != nil {
    var verr *validation.Error
    if errors.As(err, &verr) {
        // Access structured field errors
        for _, fieldErr := range verr.Fields {
            fmt.Printf("%s: %s\n", fieldErr.Path, fieldErr.Message)
        }
    }
}

Creating a Validator Instance

For more control, create a Validator instance with custom configuration:

validator := validation.MustNew(
    validation.WithMaxErrors(10),
    validation.WithRedactor(sensitiveFieldRedactor),
)

// Use in handlers
if err := validator.Validate(ctx, &req); err != nil {
    // Handle validation errors
}

New vs MustNew

There are two constructors:

// New returns an error if configuration is invalid
validator, err := validation.New(
    validation.WithMaxErrors(-1), // Invalid
)
if err != nil {
    return fmt.Errorf("failed to create validator: %w", err)
}

// MustNew panics if configuration is invalid (use in main/init)
validator := validation.MustNew(
    validation.WithMaxErrors(10),
)

Use MustNew in main() or init() where panic on startup is acceptable. Use New when you need to handle initialization errors gracefully.

Per-Call Options

Override validator configuration on a per-call basis:

validator := validation.MustNew(
    validation.WithMaxErrors(10),
)

// Override max errors for this call
err := validator.Validate(ctx, &req,
    validation.WithMaxErrors(5),
    validation.WithStrategy(validation.StrategyTags),
)

Per-call options don’t modify the validator instance - they create a temporary config for that call only.

Validating Different Types

Structs

The most common use case:

type User struct {
    Name  string `validate:"required,min=2"`
    Email string `validate:"required,email"`
}

user := User{Name: "A", Email: "invalid"}
err := validation.Validate(ctx, &user)

Pointers

Pass pointers to structs:

user := &User{Name: "Alice", Email: "alice@example.com"}
err := validation.Validate(ctx, user)

Nil Values

Validating nil values returns an error:

var user *User
err := validation.Validate(ctx, user)
// Returns: *validation.Error with code "nil_pointer"

Context Usage

The context is passed to ValidatorWithContext implementations:

type User struct {
    Email string
}

func (u *User) ValidateContext(ctx context.Context) error {
    // Access request-scoped data
    tenant := ctx.Value("tenant").(string)
    // Apply tenant-specific validation
    return nil
}

// Context is passed to ValidateContext
err := validation.Validate(ctx, &user)

For struct tags and JSON Schema validation, the context is not used (but must be provided for consistency).

Common Options

Limit Error Count

Stop validation after N errors:

err := validation.Validate(ctx, &req,
    validation.WithMaxErrors(5),
)

Choose Strategy

Explicitly select a validation strategy:

// Use only struct tags
err := validation.Validate(ctx, &req,
    validation.WithStrategy(validation.StrategyTags),
)

// Use only JSON Schema
err := validation.Validate(ctx, &req,
    validation.WithStrategy(validation.StrategyJSONSchema),
)

// Use only interface methods
err := validation.Validate(ctx, &req,
    validation.WithStrategy(validation.StrategyInterface),
)

Run All Strategies

Run all applicable strategies and aggregate errors:

err := validation.Validate(ctx, &req,
    validation.WithRunAll(true),
)

Thread Safety

Both package-level functions and Validator instances are safe for concurrent use:

validator := validation.MustNew(
    validation.WithMaxErrors(10),
)

// Safe to use from multiple goroutines
go func() {
    validator.Validate(ctx, &user1)
}()

go func() {
    validator.Validate(ctx, &user2)
}()

Default Validator

Package-level functions use a shared default validator:

// These both use the same default validator
validation.Validate(ctx, &req1)
validation.Validate(ctx, &req2)

The default validator is created with zero configuration. If you need custom options, create your own Validator instance.

Working Example

Here’s a complete example showing basic usage:

package main

import (
    "context"
    "fmt"
    "rivaas.dev/validation"
)

type CreateUserRequest struct {
    Username string `validate:"required,min=3,max=20"`
    Email    string `validate:"required,email"`
    Age      int    `validate:"min=18,max=120"`
}

func main() {
    ctx := context.Background()
    
    // Invalid request
    req := CreateUserRequest{
        Username: "ab",           // Too short
        Email:    "not-an-email", // Invalid format
        Age:      15,             // Too young
    }
    
    err := validation.Validate(ctx, &req)
    if err != nil {
        var verr *validation.Error
        if errors.As(err, &verr) {
            fmt.Println("Validation errors:")
            for _, fieldErr := range verr.Fields {
                fmt.Printf("  %s: %s\n", fieldErr.Path, fieldErr.Message)
            }
        }
    }
}

Output:

Validation errors:
  Username: min constraint failed
  Email: must be a valid email address
  Age: min constraint failed

Next Steps

3 - Struct Tags

Validate structs using go-playground/validator tags

Use struct tags with go-playground/validator syntax to validate your structs. This is the most common validation strategy in the Rivaas validation package.

Basic Syntax

Add validate tags to struct fields:

type User struct {
    Email    string `validate:"required,email"`
    Age      int    `validate:"min=18,max=120"`
    Username string `validate:"required,min=3,max=20"`
}

Tags are comma-separated constraints. Each constraint is evaluated, and all must pass for validation to succeed.

Common Validation Tags

Required Fields

type User struct {
    Email string `validate:"required"`      // Must be non-zero value
    Name  string `validate:"required"`      // Must be non-empty string
    Age   int    `validate:"required"`      // Must be non-zero number
}

String Constraints

type User struct {
    // Length constraints
    Username string `validate:"min=3,max=20"`
    Bio      string `validate:"max=500"`
    
    // Format constraints
    Email    string `validate:"email"`
    URL      string `validate:"url"`
    UUID     string `validate:"uuid"`
    
    // Character constraints
    AlphaOnly string `validate:"alpha"`
    AlphaNum  string `validate:"alphanum"`
    Numeric   string `validate:"numeric"`
}

Number Constraints

type Product struct {
    Price    float64 `validate:"min=0"`
    Quantity int     `validate:"min=1,max=1000"`
    Rating   float64 `validate:"gte=0,lte=5"`  // Greater/less than or equal
}

Comparison Operators

TagDescription
min=NMinimum value (numbers) or length (strings/slices)
max=NMaximum value (numbers) or length (strings/slices)
eq=NEqual to N
ne=NNot equal to N
gt=NGreater than N
gte=NGreater than or equal to N
lt=NLess than N
lte=NLess than or equal to N

Enum Values

type Order struct {
    Status string `validate:"oneof=pending confirmed shipped delivered"`
}

Multiple values separated by spaces.

Collection Constraints

type Request struct {
    Tags   []string `validate:"min=1,max=10,dive,min=2,max=20"`
    //                         ^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^
    //                         Array rules   Element rules
    
    Emails []string `validate:"required,dive,email"`
}

Use dive to validate elements inside slices/arrays/maps.

Nested Structs

type Address struct {
    Street string `validate:"required"`
    City   string `validate:"required"`
    Zip    string `validate:"required,numeric,len=5"`
}

type User struct {
    Name    string  `validate:"required"`
    Address Address `validate:"required"` // Validates nested struct
}

Pointer Fields

type User struct {
    Email *string `validate:"omitempty,email"`
    //                      ^^^^^^^^^ Skip validation if nil
}

Use omitempty to skip validation when the field is nil or zero-value.

Format Validation Tags

Email and Web

type Contact struct {
    Email     string `validate:"email"`
    Website   string `validate:"url"`
    Hostname  string `validate:"hostname"`
    IPAddress string `validate:"ip"`
    IPv4      string `validate:"ipv4"`
    IPv6      string `validate:"ipv6"`
}

File Paths

type Config struct {
    DataFile string `validate:"file"`      // Must be existing file
    DataDir  string `validate:"dir"`       // Must be existing directory
    FilePath string `validate:"filepath"`  // Valid file path syntax
}

Identifiers

type Resource struct {
    ID       string `validate:"uuid"`
    UUID4    string `validate:"uuid4"`
    ISBN     string `validate:"isbn"`
    CreditCard string `validate:"credit_card"`
}

Cross-Field Validation

Field Comparison

type Registration struct {
    Password        string `validate:"required,min=8"`
    ConfirmPassword string `validate:"required,eqfield=Password"`
    //                                         ^^^^^^^^^^^^^^^^
    //                                         Must equal Password field
}

Conditional Validation

type User struct {
    Type  string `validate:"oneof=personal business"`
    TaxID string `validate:"required_if=Type business"`
    //                      ^^^^^^^^^^^^^^^^^^^^^^^^
    //                      Required when Type is "business"
}

Cross-field tags:

TagDescription
eqfield=FieldMust equal another field
nefield=FieldMust not equal another field
gtfield=FieldMust be greater than another field
ltfield=FieldMust be less than another field
required_if=Field ValueRequired when field equals value
required_unless=Field ValueRequired unless field equals value
required_with=FieldRequired when field is present
required_without=FieldRequired when field is absent

Advanced Tags

Regular Expressions

type User struct {
    Phone string `validate:"required,e164"`           // E.164 phone format
    Slug  string `validate:"required,alphanum,min=3"` // URL-safe slug
}

Boolean Logic

type Product struct {
    // Must be numeric OR alpha
    Code string `validate:"numeric|alpha"`
}

Use | (OR) to allow multiple constraint sets.

Custom Formats

type Data struct {
    Datetime string `validate:"datetime=2006-01-02"`
    Date     string `validate:"datetime=2006-01-02 15:04:05"`
}

Tag Naming with JSON

By default, validation uses JSON field names in error messages:

type User struct {
    Email string `json:"email_address" validate:"required,email"`
    //            ^^^^^^^^^^^^^^^^^^^ Used in error message
}

Error message will reference email_address, not Email.

Validation Example

Complete example with various constraints:

package main

import (
    "context"
    "fmt"
    "rivaas.dev/validation"
)

type CreateUserRequest struct {
    // Required string with length constraints
    Username string `json:"username" validate:"required,min=3,max=20,alphanum"`
    
    // Valid email address
    Email string `json:"email" validate:"required,email"`
    
    // Age range
    Age int `json:"age" validate:"required,min=18,max=120"`
    
    // Password with confirmation
    Password        string `json:"password" validate:"required,min=8"`
    ConfirmPassword string `json:"confirm_password" validate:"required,eqfield=Password"`
    
    // Optional phone (validated if provided)
    Phone string `json:"phone" validate:"omitempty,e164"`
    
    // Enum value
    Role string `json:"role" validate:"required,oneof=user admin moderator"`
    
    // Nested struct
    Address Address `json:"address" validate:"required"`
    
    // Array with constraints
    Tags []string `json:"tags" validate:"min=1,max=10,dive,min=2,max=20"`
}

type Address struct {
    Street  string `json:"street" validate:"required"`
    City    string `json:"city" validate:"required"`
    State   string `json:"state" validate:"required,len=2,alpha"`
    ZipCode string `json:"zip_code" validate:"required,numeric,len=5"`
}

func main() {
    ctx := context.Background()
    
    req := CreateUserRequest{
        Username:        "ab",                // Too short
        Email:           "invalid",           // Invalid email
        Age:             15,                  // Too young
        Password:        "pass",              // Too short
        ConfirmPassword: "different",         // Doesn't match
        Phone:           "123",               // Invalid format
        Role:            "superuser",         // Not in enum
        Address:         Address{},           // Missing required fields
        Tags:            []string{"a", "bb"}, // First tag too short
    }
    
    err := validation.Validate(ctx, &req)
    if err != nil {
        var verr *validation.Error
        if errors.As(err, &verr) {
            for _, fieldErr := range verr.Fields {
                fmt.Printf("%s: %s\n", fieldErr.Path, fieldErr.Message)
            }
        }
    }
}

Tag Reference

For a complete list of available tags, see the go-playground/validator documentation.

Common categories:

  • Comparison: eq, ne, gt, gte, lt, lte, min, max, len
  • Strings: alpha, alphanum, numeric, email, url, uuid, contains, startswith, endswith
  • Numbers: Range validation, divisibility
  • Network: ip, ipv4, ipv6, hostname, mac
  • Files: file, dir, filepath
  • Cross-field: eqfield, nefield, gtfield, ltfield
  • Conditional: required_if, required_unless, required_with, required_without

Performance Considerations

  • Struct validation tags are cached after first use (fast)
  • Tag validator is initialized lazily (only when needed)
  • Thread-safe for concurrent validation
  • No runtime overhead for unused tags

Next Steps

4 - JSON Schema Validation

Validate structs using JSON Schema

Validate structs using JSON Schema. Implement the JSONSchemaProvider interface to use this feature. This provides RFC-compliant JSON Schema validation as an alternative to struct tags.

JSONSchemaProvider Interface

Implement the JSONSchemaProvider interface on your struct:

type JSONSchemaProvider interface {
    JSONSchema() (id, schema string)
}

The method returns:

  • id: Unique schema identifier for caching.
  • schema: JSON Schema as a string in JSON format.

Basic Example

type User struct {
    Email string `json:"email"`
    Age   int    `json:"age"`
}

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{
        "type": "object",
        "properties": {
            "email": {"type": "string", "format": "email"},
            "age": {"type": "integer", "minimum": 18}
        },
        "required": ["email"]
    }`
}

// Validation automatically uses the schema
err := validation.Validate(ctx, &user)

JSON Schema Syntax

Basic Types

func (p Product) JSONSchema() (id, schema string) {
    return "product-v1", `{
        "type": "object",
        "properties": {
            "name": {"type": "string"},
            "price": {"type": "number"},
            "inStock": {"type": "boolean"},
            "tags": {"type": "array", "items": {"type": "string"}},
            "metadata": {"type": "object"}
        }
    }`
}

String Constraints

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{
        "type": "object",
        "properties": {
            "username": {
                "type": "string",
                "minLength": 3,
                "maxLength": 20,
                "pattern": "^[a-zA-Z0-9_]+$"
            },
            "email": {
                "type": "string",
                "format": "email"
            },
            "website": {
                "type": "string",
                "format": "uri"
            }
        }
    }`
}

Number Constraints

func (p Product) JSONSchema() (id, schema string) {
    return "product-v1", `{
        "type": "object",
        "properties": {
            "price": {
                "type": "number",
                "minimum": 0,
                "exclusiveMinimum": true
            },
            "quantity": {
                "type": "integer",
                "minimum": 0,
                "maximum": 1000
            },
            "rating": {
                "type": "number",
                "minimum": 0,
                "maximum": 5,
                "multipleOf": 0.5
            }
        }
    }`
}

Array Constraints

func (r Request) JSONSchema() (id, schema string) {
    return "request-v1", `{
        "type": "object",
        "properties": {
            "tags": {
                "type": "array",
                "items": {"type": "string"},
                "minItems": 1,
                "maxItems": 10,
                "uniqueItems": true
            }
        }
    }`
}

Enum Values

func (o Order) JSONSchema() (id, schema string) {
    return "order-v1", `{
        "type": "object",
        "properties": {
            "status": {
                "type": "string",
                "enum": ["pending", "confirmed", "shipped", "delivered"]
            }
        }
    }`
}

Nested Objects

type User struct {
    Name    string  `json:"name"`
    Address Address `json:"address"`
}

type Address struct {
    Street string `json:"street"`
    City   string `json:"city"`
    Zip    string `json:"zip"`
}

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{
        "type": "object",
        "properties": {
            "name": {"type": "string"},
            "address": {
                "type": "object",
                "properties": {
                    "street": {"type": "string"},
                    "city": {"type": "string"},
                    "zip": {"type": "string", "pattern": "^[0-9]{5}$"}
                },
                "required": ["street", "city", "zip"]
            }
        },
        "required": ["name", "address"]
    }`
}

Format Validation

JSON Schema supports various format validators:

func (c Contact) JSONSchema() (id, schema string) {
    return "contact-v1", `{
        "type": "object",
        "properties": {
            "email": {"type": "string", "format": "email"},
            "website": {"type": "string", "format": "uri"},
            "ipAddress": {"type": "string", "format": "ipv4"},
            "createdAt": {"type": "string", "format": "date-time"},
            "birthDate": {"type": "string", "format": "date"}
        }
    }`
}

Supported formats:

  • email - Email address
  • uri / url - URL
  • hostname - DNS hostname
  • ipv4 / ipv6 - IP addresses
  • date - Date (YYYY-MM-DD)
  • date-time - RFC3339 date-time
  • uuid - UUID

Schema Caching

Schemas are cached by ID for performance:

func (u User) JSONSchema() (id, schema string) {
    // ID is used as cache key
    return "user-v1", `{...}`
    //     ^^^^^^^^ Cached after first validation
}

Cache is LRU with configurable size:

validator := validation.MustNew(
    validation.WithMaxCachedSchemas(2048), // Default: 1024
)

Override Schema Per-Call

Provide a custom schema for a specific validation:

customSchema := `{
    "type": "object",
    "properties": {
        "email": {"type": "string", "format": "email"}
    },
    "required": ["email"]
}`

err := validator.Validate(ctx, &user,
    validation.WithCustomSchema("custom-user", customSchema),
)

This overrides the JSONSchemaProvider for this call only.

Strategy Selection

By default, JSON Schema has lower priority than struct tags and interface methods. Explicitly select it:

err := validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyJSONSchema),
)

Or use automatic strategy selection (default behavior):

// Automatically uses JSON Schema if:
// 1. Type implements JSONSchemaProvider
// 2. No Validate() or ValidateContext() method
// 3. No struct tags present
err := validation.Validate(ctx, &user)

Combining with Other Strategies

Run all strategies and aggregate errors:

type User struct {
    Email string `json:"email" validate:"required,email"` // Struct tag
}

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{
        "type": "object",
        "properties": {
            "email": {"type": "string", "format": "email"}
        }
    }`
}

// Run both struct tag and JSON Schema validation
err := validation.Validate(ctx, &user,
    validation.WithRunAll(true),
)

Schema Validation Errors

JSON Schema errors are returned as FieldError values:

err := validation.Validate(ctx, &user)
if err != nil {
    var verr *validation.Error
    if errors.As(err, &verr) {
        for _, fieldErr := range verr.Fields {
            fmt.Printf("Path: %s\n", fieldErr.Path)
            fmt.Printf("Code: %s\n", fieldErr.Code)       // e.g., "schema.type"
            fmt.Printf("Message: %s\n", fieldErr.Message)
        }
    }
}

Error codes follow the pattern schema.<constraint>:

  • schema.type - Type mismatch
  • schema.required - Missing required field
  • schema.minimum - Below minimum value
  • schema.pattern - Pattern mismatch
  • schema.format - Format validation failed

Complete Example

package main

import (
    "context"
    "fmt"
    "rivaas.dev/validation"
)

type CreateUserRequest struct {
    Username string `json:"username"`
    Email    string `json:"email"`
    Age      int    `json:"age"`
}

func (r CreateUserRequest) JSONSchema() (id, schema string) {
    return "create-user-v1", `{
        "$schema": "http://json-schema.org/draft-07/schema#",
        "type": "object",
        "properties": {
            "username": {
                "type": "string",
                "minLength": 3,
                "maxLength": 20,
                "pattern": "^[a-zA-Z0-9_]+$"
            },
            "email": {
                "type": "string",
                "format": "email"
            },
            "age": {
                "type": "integer",
                "minimum": 18,
                "maximum": 120
            }
        },
        "required": ["username", "email", "age"],
        "additionalProperties": false
    }`
}

func main() {
    ctx := context.Background()
    
    req := CreateUserRequest{
        Username: "ab",           // Too short
        Email:    "not-an-email", // Invalid format
        Age:      15,             // Below minimum
    }
    
    // Explicitly use JSON Schema strategy
    err := validation.Validate(ctx, &req,
        validation.WithStrategy(validation.StrategyJSONSchema),
    )
    
    if err != nil {
        var verr *validation.Error
        if errors.As(err, &verr) {
            for _, fieldErr := range verr.Fields {
                fmt.Printf("%s: %s\n", fieldErr.Path, fieldErr.Message)
            }
        }
    }
}

Advantages of JSON Schema

  • Standard: RFC-compliant, widely supported format
  • Portable: Schema can be shared with frontend/documentation
  • Flexible: Complex validation logic without code
  • Versioned: Easy to version schemas with ID

Disadvantages

  • Verbose: More code than struct tags
  • Runtime: Schema parsing has overhead (mitigated by caching)
  • Complexity: Learning curve for JSON Schema syntax

When to Use JSON Schema

Use JSON Schema when:

  • You need to share validation rules with frontend
  • You have complex validation logic
  • You want portable, language-independent validation
  • You need to version validation rules

Use struct tags when:

  • You prefer concise, declarative validation
  • You only validate server-side
  • You want minimal overhead

JSON Schema Resources

Next Steps

5 - Custom Validation Interfaces

Implement custom validation methods with Validate() and ValidateContext()

Implement custom validation logic by adding Validate() or ValidateContext() methods to your structs. This provides the most flexible validation approach for complex business rules.

ValidatorInterface

Implement the ValidatorInterface for simple custom validation:

type ValidatorInterface interface {
    Validate() error
}

Basic Example

type User struct {
    Email string
    Name  string
}

func (u *User) Validate() error {
    if !strings.Contains(u.Email, "@") {
        return errors.New("email must contain @")
    }
    if len(u.Name) < 2 {
        return errors.New("name too short")
    }
    return nil
}

// Validation automatically calls u.Validate()
err := validation.Validate(ctx, &user)

Returning Structured Errors

Return *validation.Error for detailed field-level errors:

func (u *User) Validate() error {
    var verr validation.Error
    
    if !strings.Contains(u.Email, "@") {
        verr.Add("email", "format", "must contain @", nil)
    }
    
    if len(u.Name) < 2 {
        verr.Add("name", "length", "must be at least 2 characters", nil)
    }
    
    if verr.HasErrors() {
        return &verr
    }
    return nil
}

ValidatorWithContext

Implement ValidatorWithContext for context-aware validation:

type ValidatorWithContext interface {
    ValidateContext(context.Context) error
}

This is preferred when you need access to request-scoped data.

Context-Aware Validation

type User struct {
    Email    string
    TenantID string
}

func (u *User) ValidateContext(ctx context.Context) error {
    // Access context values
    tenant := ctx.Value("tenant").(string)
    
    // Tenant-specific validation
    if u.TenantID != tenant {
        return errors.New("user does not belong to this tenant")
    }
    
    // Additional validation
    if !strings.HasSuffix(u.Email, "@"+tenant+".com") {
        return fmt.Errorf("email must be from %s.com domain", tenant)
    }
    
    return nil
}

Database Validation

type User struct {
    Username string
    Email    string
}

func (u *User) ValidateContext(ctx context.Context) error {
    // Get database from context
    db := ctx.Value("db").(*sql.DB)
    
    // Check username uniqueness
    var exists bool
    err := db.QueryRowContext(ctx,
        "SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)",
        u.Username,
    ).Scan(&exists)
    
    if err != nil {
        return fmt.Errorf("failed to check username: %w", err)
    }
    
    if exists {
        return errors.New("username already taken")
    }
    
    return nil
}

Interface Priority

When a type implements ValidatorInterface or ValidatorWithContext, those methods have the highest priority:

Priority Order:

  1. ValidateContext(ctx) or Validate() (highest)
  2. Struct tags (validate:"...")
  3. JSON Schema (JSONSchemaProvider)
type User struct {
    Email string `validate:"required,email"` // Lower priority
}

func (u *User) Validate() error {
    // This runs instead of struct tags
    return customEmailValidation(u.Email)
}

Override this behavior by explicitly selecting a strategy:

// Skip interface method, use struct tags
err := validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyTags),
)

Combining with Other Strategies

Run interface validation along with other strategies:

type User struct {
    Email string `validate:"required,email"`
}

func (u *User) Validate() error {
    // Custom business logic
    if isBlacklisted(u.Email) {
        return errors.New("email is blacklisted")
    }
    return nil
}

// Run both interface method AND struct tag validation
err := validation.Validate(ctx, &user,
    validation.WithRunAll(true),
)

All errors are aggregated into a single *validation.Error.

Pointer vs Value Receivers

The validation package works with both pointer and value receivers:

func (u *User) Validate() error {
    // Can modify the struct if needed
    u.Email = strings.ToLower(u.Email)
    return nil
}

Value Receiver

func (u User) Validate() error {
    // Read-only validation
    if u.Email == "" {
        return errors.New("email required")
    }
    return nil
}

Use pointer receivers when you need to modify the struct during validation (normalization, etc.).

Complex Validation Example

type CreateOrderRequest struct {
    UserID    int
    Items     []OrderItem
    CouponCode string
    Total     float64
}

type OrderItem struct {
    ProductID int
    Quantity  int
    Price     float64
}

func (r *CreateOrderRequest) ValidateContext(ctx context.Context) error {
    var verr validation.Error
    
    // Validate user exists
    if !userExists(ctx, r.UserID) {
        verr.Add("user_id", "not_found", "user does not exist", nil)
    }
    
    // Validate items
    if len(r.Items) == 0 {
        verr.Add("items", "required", "at least one item required", nil)
    }
    
    var calculatedTotal float64
    for i, item := range r.Items {
        // Validate product exists and price matches
        product, err := getProduct(ctx, item.ProductID)
        if err != nil {
            verr.Add(
                fmt.Sprintf("items.%d.product_id", i),
                "not_found",
                "product does not exist",
                nil,
            )
            continue
        }
        
        if item.Price != product.Price {
            verr.Add(
                fmt.Sprintf("items.%d.price", i),
                "mismatch",
                "price does not match current product price",
                map[string]any{
                    "expected": product.Price,
                    "actual":   item.Price,
                },
            )
        }
        
        if item.Quantity < 1 {
            verr.Add(
                fmt.Sprintf("items.%d.quantity", i),
                "invalid",
                "quantity must be at least 1",
                nil,
            )
        }
        
        calculatedTotal += item.Price * float64(item.Quantity)
    }
    
    // Validate coupon if provided
    if r.CouponCode != "" {
        discount, err := validateCoupon(ctx, r.CouponCode)
        if err != nil {
            verr.Add("coupon_code", "invalid", err.Error(), nil)
        } else {
            calculatedTotal -= discount
        }
    }
    
    // Validate total matches calculation
    if math.Abs(r.Total-calculatedTotal) > 0.01 {
        verr.Add(
            "total",
            "mismatch",
            "total does not match item prices",
            map[string]any{
                "expected": calculatedTotal,
                "actual":   r.Total,
            },
        )
    }
    
    if verr.HasErrors() {
        return &verr
    }
    return nil
}

Testing Interface Validation

Test your validation methods directly:

func TestUserValidation(t *testing.T) {
    tests := []struct {
        name    string
        user    User
        wantErr bool
    }{
        {
            name:    "valid user",
            user:    User{Email: "test@example.com", Name: "Alice"},
            wantErr: false,
        },
        {
            name:    "invalid email",
            user:    User{Email: "invalid", Name: "Alice"},
            wantErr: true,
        },
        {
            name:    "short name",
            user:    User{Email: "test@example.com", Name: "A"},
            wantErr: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := tt.user.Validate()
            if (err != nil) != tt.wantErr {
                t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Best Practices

1. Keep Methods Focused

// Good: Focused validation
func (u *User) Validate() error {
    if err := validateEmail(u.Email); err != nil {
        return err
    }
    if err := validateName(u.Name); err != nil {
        return err
    }
    return nil
}

// Bad: Too much logic in one method
func (u *User) Validate() error {
    // 200 lines of validation code...
}

2. Return Structured Errors

// Good: Structured errors
func (u *User) Validate() error {
    var verr validation.Error
    verr.Add("email", "invalid", "must be valid email", nil)
    return &verr
}

// Bad: Generic errors
func (u *User) Validate() error {
    return errors.New("email invalid")
}

3. Use Context for External Dependencies

// Good: Dependencies from context
func (u *User) ValidateContext(ctx context.Context) error {
    db := ctx.Value("db").(*sql.DB)
    return checkUsernameUnique(ctx, db, u.Username)
}

// Bad: Global dependencies
var globalDB *sql.DB
func (u *User) Validate() error {
    return checkUsernameUnique(context.Background(), globalDB, u.Username)
}

4. Consider Performance

// Good: Fast validation first
func (u *User) ValidateContext(ctx context.Context) error {
    // Quick checks first
    if u.Email == "" {
        return errors.New("email required")
    }
    
    // Expensive DB check last
    return checkEmailUnique(ctx, u.Email)
}

Error Metadata

Add metadata to errors for better debugging:

func (u *User) Validate() error {
    var verr validation.Error
    
    verr.Add("email", "blacklisted", "email domain is blacklisted", map[string]any{
        "domain":     extractDomain(u.Email),
        "reason":     "spam",
        "blocked_at": time.Now(),
    })
    
    return &verr
}

Next Steps

6 - Partial Validation

Validate only provided fields in PATCH requests

Partial validation is essential for PATCH requests. Only provided fields should be validated. Absent fields are ignored even if they have “required” constraints.

The Problem

Consider a user update endpoint:

type UpdateUserRequest struct {
    Email string `validate:"required,email"`
    Name  string `validate:"required,min=2"`
    Age   int    `validate:"min=18"`
}

With normal validation, a PATCH request like {"email": "new@example.com"} would fail. The name field is required but not provided. Partial validation solves this.

PresenceMap

A PresenceMap tracks which fields are present in the request:

type PresenceMap map[string]bool

Keys are JSON field paths (e.g., "email", "address.city", "items.0.name").

Computing Presence

Use ComputePresence to analyze raw JSON:

rawJSON := []byte(`{"email": "new@example.com"}`)

presence, err := validation.ComputePresence(rawJSON)
if err != nil {
    return fmt.Errorf("failed to compute presence: %w", err)
}

// presence = {"email": true}

ValidatePartial

Use ValidatePartial to validate only present fields:

func UpdateUserHandler(w http.ResponseWriter, r *http.Request) {
    // Read raw body
    rawJSON, _ := io.ReadAll(r.Body)
    
    // Compute presence
    presence, _ := validation.ComputePresence(rawJSON)
    
    // Parse into struct
    var req UpdateUserRequest
    json.Unmarshal(rawJSON, &req)
    
    // Validate only present fields
    err := validation.ValidatePartial(ctx, &req, presence)
    if err != nil {
        // Handle validation error
    }
}

Complete PATCH Example

type UpdateUserRequest struct {
    Email *string `json:"email" validate:"omitempty,email"`
    Name  *string `json:"name" validate:"omitempty,min=2"`
    Age   *int    `json:"age" validate:"omitempty,min=18"`
}

func UpdateUser(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    // Read raw body
    rawJSON, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "failed to read body", http.StatusBadRequest)
        return
    }
    
    // Compute which fields are present
    presence, err := validation.ComputePresence(rawJSON)
    if err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    
    // Parse into struct
    var req UpdateUserRequest
    if err := json.Unmarshal(rawJSON, &req); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    
    // Validate only present fields
    if err := validation.ValidatePartial(ctx, &req, presence); err != nil {
        var verr *validation.Error
        if errors.As(err, &verr) {
            // Return field errors
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusUnprocessableEntity)
            json.NewEncoder(w).Encode(verr)
            return
        }
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Update user with provided fields
    updateUser(ctx, req)
    
    w.WriteHeader(http.StatusOK)
}

Nested Structures

Presence tracking works with nested objects and arrays:

type UpdateOrderRequest struct {
    Status string  `json:"status"`
    Items  []Item  `json:"items"`
    Address Address `json:"address"`
}

type Item struct {
    ProductID int `json:"product_id"`
    Quantity  int `json:"quantity"`
}

type Address struct {
    Street string `json:"street"`
    City   string `json:"city"`
    Zip    string `json:"zip"`
}

rawJSON := []byte(`{
    "status": "confirmed",
    "items": [
        {"product_id": 123, "quantity": 2}
    ],
    "address": {
        "city": "San Francisco"
    }
}`)

presence, _ := validation.ComputePresence(rawJSON)
// presence = {
//     "status": true,
//     "items": true,
//     "items.0": true,
//     "items.0.product_id": true,
//     "items.0.quantity": true,
//     "address": true,
//     "address.city": true,
// }

Only address.city was provided, so address.street and address.zip won’t be validated.

Using WithPresence Option

You can also use the WithPresence option directly:

presence, _ := validation.ComputePresence(rawJSON)

err := validation.Validate(ctx, &req,
    validation.WithPartial(true),
    validation.WithPresence(presence),
)

PresenceMap Methods

Has

Check if an exact path is present:

if presence.Has("email") {
    // Email field was provided
}

HasPrefix

Check if any nested path exists:

if presence.HasPrefix("address") {
    // At least one address field was provided
    // (e.g., "address.city" or "address.street")
}

LeafPaths

Get only the deepest paths (no parent paths):

presence := PresenceMap{
    "address": true,
    "address.city": true,
    "address.street": true,
}

leaves := presence.LeafPaths()
// returns: ["address.city", "address.street"]
// "address" is excluded (it has children)

Useful for validating only actual data fields, not parent objects.

Pointer Fields for PATCH

Use pointers to distinguish between “not provided” and “zero value”:

type UpdateUserRequest struct {
    Email *string `json:"email"`
    Age   *int    `json:"age"`
    Active *bool  `json:"active"`
}

// Email: not provided
// Age: 0
// Active: false
rawJSON := []byte(`{"age": 0, "active": false}`)

With presence tracking:

  • email not in presence map → skip validation
  • age and active in presence map → validate even though they’re zero values

Struct Tag Strategy

For partial validation with struct tags, use omitempty instead of required:

// Good for PATCH
type UpdateUserRequest struct {
    Email string `json:"email" validate:"omitempty,email"`
    Age   int    `json:"age" validate:"omitempty,min=18"`
}

// Bad for PATCH
type UpdateUserRequest struct {
    Email string `json:"email" validate:"required,email"` // Will fail if not provided
    Age   int    `json:"age" validate:"required,min=18"`  // Will fail if not provided
}

Custom Interface with Partial Validation

Access the presence map in custom validation:

type UpdateOrderRequest struct {
    Items []OrderItem
}

func (r *UpdateOrderRequest) ValidateContext(ctx context.Context) error {
    // Get presence from context (if available)
    presence := ctx.Value("presence").(validation.PresenceMap)
    
    // Only validate items if provided
    if presence.HasPrefix("items") {
        if len(r.Items) == 0 {
            return errors.New("items cannot be empty when provided")
        }
    }
    
    return nil
}

// Pass presence via context
ctx = context.WithValue(ctx, "presence", presence)
err := validation.ValidatePartial(ctx, &req, presence)

Performance Considerations

  • ComputePresence parses JSON once (fast)
  • Presence map is cached per request
  • No reflection overhead for presence checks
  • Memory usage: ~100 bytes per field path

Limitations

Deep Nesting

ComputePresence has a maximum nesting depth of 100 to prevent stack overflow:

// This will stop at depth 100
deeplyNested := generateDeeplyNestedJSON(150)
presence, _ := validation.ComputePresence(deeplyNested)
// Only tracks first 100 levels

Maximum Fields

For security, limit the number of fields in partial validation:

validator := validation.MustNew(
    validation.WithMaxFields(5000), // Default: 10000
)

Testing Partial Validation

func TestPartialValidation(t *testing.T) {
    tests := []struct {
        name    string
        json    string
        wantErr bool
    }{
        {
            name:    "valid email update",
            json:    `{"email": "new@example.com"}`,
            wantErr: false,
        },
        {
            name:    "invalid email update",
            json:    `{"email": "invalid"}`,
            wantErr: true,
        },
        {
            name:    "empty body",
            json:    `{}`,
            wantErr: false, // No fields to validate
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            presence, _ := validation.ComputePresence([]byte(tt.json))
            
            var req UpdateUserRequest
            json.Unmarshal([]byte(tt.json), &req)
            
            err := validation.ValidatePartial(context.Background(), &req, presence)
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidatePartial() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Best Practices

1. Always Use Pointers for Optional Fields

// Good
type UpdateUserRequest struct {
    Email *string `json:"email" validate:"omitempty,email"`
    Age   *int    `json:"age" validate:"omitempty,min=18"`
}

// Bad - can't distinguish between "not provided" and "zero value"
type UpdateUserRequest struct {
    Email string `json:"email" validate:"omitempty,email"`
    Age   int    `json:"age" validate:"omitempty,min=18"`
}

2. Compute Presence Once

// Good
presence, _ := validation.ComputePresence(rawJSON)
err1 := validation.ValidatePartial(ctx, &req1, presence)
err2 := validation.ValidatePartial(ctx, &req2, presence)

// Bad - recomputes presence
validation.ValidatePartial(ctx, &req1, computePresence(rawJSON))
validation.ValidatePartial(ctx, &req2, computePresence(rawJSON))

3. Handle Empty Bodies

rawJSON, _ := io.ReadAll(r.Body)

if len(rawJSON) == 0 {
    http.Error(w, "empty body", http.StatusBadRequest)
    return
}

presence, _ := validation.ComputePresence(rawJSON)

4. Use omitempty Instead of required

// Good for PATCH
validate:"omitempty,email"

// Bad for PATCH
validate:"required,email"

Next Steps

7 - Error Handling

Work with structured validation errors

Validation errors in the Rivaas validation package are structured and detailed. They provide field-level error information with codes, messages, and metadata.

Error Types

validation.Error

The main validation error type containing multiple field errors:

type Error struct {
    Fields    []FieldError // List of field errors.
    Truncated bool         // True if errors were truncated due to maxErrors limit.
}

FieldError

Individual field error with detailed information:

type FieldError struct {
    Path    string         // JSON path like "items.2.price".
    Code    string         // Stable code like "tag.required", "schema.type".
    Message string         // Human-readable message.
    Meta    map[string]any // Additional metadata like tag, param, value.
}

Checking for Validation Errors

Use errors.As to extract structured errors:

err := validation.Validate(ctx, &req)
if err != nil {
    var verr *validation.Error
    if errors.As(err, &verr) {
        // Access structured field errors
        for _, fieldErr := range verr.Fields {
            fmt.Printf("%s: %s\n", fieldErr.Path, fieldErr.Message)
        }
    }
}

Error Codes

Error codes follow a consistent pattern for programmatic handling:

Struct Tag Errors

Format: tag.<tagname>

Code: "tag.required"     // Required field missing
Code: "tag.email"        // Email format invalid
Code: "tag.min"          // Below minimum value/length
Code: "tag.max"          // Above maximum value/length
Code: "tag.oneof"        // Value not in enum

JSON Schema Errors

Format: schema.<constraint>

Code: "schema.type"      // Type mismatch
Code: "schema.required"  // Missing required field
Code: "schema.minimum"   // Below minimum value
Code: "schema.pattern"   // Pattern mismatch
Code: "schema.format"    // Format validation failed

Interface Method Errors

Custom codes from your validation methods:

Code: "validation_error" // Generic validation error
Code: "custom_code"      // Your custom code

Accessing Field Errors

Iterate Over All Errors

var verr *validation.Error
if errors.As(err, &verr) {
    for _, fieldErr := range verr.Fields {
        log.Printf("Field: %s, Code: %s, Message: %s",
            fieldErr.Path,
            fieldErr.Code,
            fieldErr.Message,
        )
    }
}

Check for Specific Field

var verr *validation.Error
if errors.As(err, &verr) {
    if verr.Has("email") {
        fmt.Println("Email field has an error")
    }
}

Get First Error for Field

var verr *validation.Error
if errors.As(err, &verr) {
    fieldErr := verr.GetField("email")
    if fieldErr != nil {
        fmt.Printf("Email error: %s\n", fieldErr.Message)
    }
}

Check for Specific Error Code

var verr *validation.Error
if errors.As(err, &verr) {
    if verr.HasCode("tag.required") {
        fmt.Println("Some required fields are missing")
    }
}

Error Metadata

Errors may include additional metadata:

var verr *validation.Error
if errors.As(err, &verr) {
    for _, fieldErr := range verr.Fields {
        fmt.Printf("Path: %s\n", fieldErr.Path)
        fmt.Printf("Code: %s\n", fieldErr.Code)
        fmt.Printf("Message: %s\n", fieldErr.Message)
        
        // Access metadata
        if tag, ok := fieldErr.Meta["tag"].(string); ok {
            fmt.Printf("Tag: %s\n", tag)
        }
        if param, ok := fieldErr.Meta["param"].(string); ok {
            fmt.Printf("Param: %s\n", param)
        }
        if value := fieldErr.Meta["value"]; value != nil {
            fmt.Printf("Value: %v\n", value)
        }
    }
}

Common metadata fields:

  • tag - The validation tag that failed (struct tags)
  • param - Tag parameter (e.g., “18” for min=18)
  • value - The actual value (may be redacted)
  • expected - Expected value for comparison errors
  • actual - Actual value for comparison errors

Error Messages

Default Messages

The package provides clear default messages:

"is required"
"must be a valid email address"
"must be at least 18"
"must be one of: pending, confirmed, shipped"

Custom Messages

Customize error messages when creating a validator:

validator := validation.MustNew(
    validation.WithMessages(map[string]string{
        "required": "cannot be empty",
        "email":    "invalid email format",
        "min":      "too small",
    }),
)

Dynamic Messages

Use WithMessageFunc for parameterized tags:

validator := validation.MustNew(
    validation.WithMessageFunc("min", func(param string, kind reflect.Kind) string {
        if kind == reflect.String {
            return fmt.Sprintf("must be at least %s characters", param)
        }
        return fmt.Sprintf("must be at least %s", param)
    }),
)

Limiting Errors

Max Errors

Limit the number of errors returned:

err := validation.Validate(ctx, &req,
    validation.WithMaxErrors(5),
)

var verr *validation.Error
if errors.As(err, &verr) {
    if verr.Truncated {
        fmt.Println("More errors exist (showing first 5)")
    }
}

Fail Fast

Stop at the first error:

err := validation.Validate(ctx, &req,
    validation.WithMaxErrors(1),
)

Sorting Errors

Sort errors for consistent output:

var verr *validation.Error
if errors.As(err, &verr) {
    verr.Sort() // Sort by path, then by code
    
    for _, fieldErr := range verr.Fields {
        fmt.Printf("%s: %s\n", fieldErr.Path, fieldErr.Message)
    }
}

HTTP Error Responses

JSON Error Response

func HandleValidationError(w http.ResponseWriter, err error) {
    var verr *validation.Error
    if errors.As(err, &verr) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusUnprocessableEntity)
        json.NewEncoder(w).Encode(map[string]any{
            "error": "validation_failed",
            "fields": verr.Fields,
        })
        return
    }
    
    // Other error types
    http.Error(w, "internal server error", http.StatusInternalServerError)
}

Example response:

{
    "error": "validation_failed",
    "fields": [
        {
            "path": "email",
            "code": "tag.email",
            "message": "must be a valid email address",
            "meta": {
                "tag": "email",
                "value": "[REDACTED]"
            }
        },
        {
            "path": "age",
            "code": "tag.min",
            "message": "must be at least 18",
            "meta": {
                "tag": "min",
                "param": "18",
                "value": 15
            }
        }
    ]
}

Problem Details (RFC 7807)

func HandleValidationErrorProblemDetails(w http.ResponseWriter, err error) {
    var verr *validation.Error
    if !errors.As(err, &verr) {
        http.Error(w, "internal server error", http.StatusInternalServerError)
        return
    }
    
    // Convert to Problem Details format
    problems := make([]map[string]any, len(verr.Fields))
    for i, fieldErr := range verr.Fields {
        problems[i] = map[string]any{
            "field":   fieldErr.Path,
            "code":    fieldErr.Code,
            "message": fieldErr.Message,
        }
    }
    
    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(http.StatusUnprocessableEntity)
    json.NewEncoder(w).Encode(map[string]any{
        "type":     "https://example.com/problems/validation-error",
        "title":    "Validation Error",
        "status":   422,
        "detail":   verr.Error(),
        "instance": r.URL.Path,
        "errors":   problems,
    })
}

Creating Custom Errors

Adding Errors Manually

var verr validation.Error

verr.Add("email", "invalid", "email is blacklisted", map[string]any{
    "domain": "example.com",
    "reason": "spam",
})

verr.Add("password", "weak", "password is too weak", nil)

if verr.HasErrors() {
    return &verr
}

Combining Errors

var allErrors validation.Error

// Add errors from multiple sources
allErrors.AddError(err1)
allErrors.AddError(err2)
allErrors.AddError(err3)

if allErrors.HasErrors() {
    return &allErrors
}

Error Interface Implementations

The Error type implements several interfaces:

error Interface

err := validation.Validate(ctx, &req)
fmt.Println(err.Error())
// Output: "validation failed: email: must be valid email; age: must be at least 18"

errors.Is

if errors.Is(err, validation.ErrValidation) {
    // This is a validation error
}

rivaas.dev/errors Interfaces

The Error type implements additional interfaces for the Rivaas error handling system:

// ErrorType - HTTP status code
func (e Error) HTTPStatus() int {
    return 422 // Unprocessable Entity
}

// ErrorCode - Stable error code
func (e Error) Code() string {
    return "validation_error"
}

// ErrorDetails - Detailed error information
func (e Error) Details() any {
    return e.Fields
}

Nil and Empty Errors

Nil Pointer Errors

var user *User
err := validation.Validate(ctx, user)
// Returns: *validation.Error with code "nil_pointer"

Invalid Value Errors

var invalid interface{} = nil
err := validation.Validate(ctx, invalid)
// Returns: *validation.Error with code "invalid"

Logging Errors

Structured Logging

var verr *validation.Error
if errors.As(err, &verr) {
    for _, fieldErr := range verr.Fields {
        log.With(
            "field", fieldErr.Path,
            "code", fieldErr.Code,
            "message", fieldErr.Message,
        ).Warn("validation failed")
    }
}

Summary Logging

var verr *validation.Error
if errors.As(err, &verr) {
    fieldPaths := make([]string, len(verr.Fields))
    for i, fieldErr := range verr.Fields {
        fieldPaths[i] = fieldErr.Path
    }
    
    log.With(
        "error_count", len(verr.Fields),
        "fields", strings.Join(fieldPaths, ", "),
    ).Warn("validation failed")
}

Testing Error Handling

func TestValidationErrors(t *testing.T) {
    req := CreateUserRequest{
        Email: "invalid",
        Age:   15,
    }
    
    err := validation.Validate(context.Background(), &req)
    
    var verr *validation.Error
    if !errors.As(err, &verr) {
        t.Fatal("expected validation.Error")
    }
    
    // Check error count
    if len(verr.Fields) != 2 {
        t.Errorf("expected 2 errors, got %d", len(verr.Fields))
    }
    
    // Check specific field error
    if !verr.Has("email") {
        t.Error("expected email error")
    }
    
    // Check error code
    if !verr.HasCode("tag.email") {
        t.Error("expected tag.email error code")
    }
    
    // Check error message
    emailErr := verr.GetField("email")
    if emailErr == nil {
        t.Fatal("email error not found")
    }
    if !strings.Contains(emailErr.Message, "email") {
        t.Errorf("unexpected message: %s", emailErr.Message)
    }
}

Next Steps

8 - Custom Validators

Register custom validation tags and functions

Extend the validation package with custom validation tags and functions to handle domain-specific validation rules.

Custom Validation Tags

Register custom tags for use in struct tags with WithCustomTag.

import (
    "github.com/go-playground/validator/v10"
    "rivaas.dev/validation"
)

validator := validation.MustNew(
    validation.WithCustomTag("phone", func(fl validator.FieldLevel) bool {
        return phoneRegex.MatchString(fl.Field().String())
    }),
)

type User struct {
    Phone string `validate:"phone"`
}

FieldLevel Interface

Custom tag functions receive a validator.FieldLevel with methods to access field information.

type FieldLevel interface {
    Field() reflect.Value         // The field being validated
    FieldName() string             // Field name
    StructFieldName() string       // Struct field name
    Param() string                 // Tag parameter
    GetStructFieldOK() (reflect.Value, reflect.Kind, bool)
    Parent() reflect.Value         // Parent struct
}

Simple Custom Tags

Phone Number Validation

import "regexp"

var phoneRegex = regexp.MustCompile(`^\+?[1-9]\d{1,14}$`)

validator := validation.MustNew(
    validation.WithCustomTag("phone", func(fl validator.FieldLevel) bool {
        return phoneRegex.MatchString(fl.Field().String())
    }),
)

type Contact struct {
    Phone string `validate:"required,phone"`
}

Username Validation

var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`)

validator := validation.MustNew(
    validation.WithCustomTag("username", func(fl validator.FieldLevel) bool {
        username := fl.Field().String()
        return usernameRegex.MatchString(username)
    }),
)

type User struct {
    Username string `validate:"required,username"`
}

Slug Validation

var slugRegex = regexp.MustCompile(`^[a-z0-9-]+$`)

validator := validation.MustNew(
    validation.WithCustomTag("slug", func(fl validator.FieldLevel) bool {
        return slugRegex.MatchString(fl.Field().String())
    }),
)

type Article struct {
    Slug string `validate:"required,slug"`
}

Advanced Custom Tags

Password Strength

import "unicode"

func strongPassword(fl validator.FieldLevel) bool {
    password := fl.Field().String()
    
    if len(password) < 8 {
        return false
    }
    
    var hasUpper, hasLower, hasDigit, hasSpecial bool
    for _, c := range password {
        switch {
        case unicode.IsUpper(c):
            hasUpper = true
        case unicode.IsLower(c):
            hasLower = true
        case unicode.IsDigit(c):
            hasDigit = true
        case unicode.IsPunct(c) || unicode.IsSymbol(c):
            hasSpecial = true
        }
    }
    
    return hasUpper && hasLower && hasDigit && hasSpecial
}

validator := validation.MustNew(
    validation.WithCustomTag("strong_password", strongPassword),
)

type Registration struct {
    Password string `validate:"required,strong_password"`
}

Parameterized Tags

// Custom tag with parameter: divisible_by=N
func divisibleBy(fl validator.FieldLevel) bool {
    param := fl.Param() // Get parameter value
    divisor, err := strconv.Atoi(param)
    if err != nil {
        return false
    }
    
    value := fl.Field().Int()
    return value%int64(divisor) == 0
}

validator := validation.MustNew(
    validation.WithCustomTag("divisible_by", divisibleBy),
)

type Product struct {
    Quantity int `validate:"required,divisible_by=5"`
}

Cross-Field Validation

// Validate that EndDate is after StartDate
func afterStartDate(fl validator.FieldLevel) bool {
    endDate := fl.Field().Interface().(time.Time)
    
    // Access parent struct
    parent := fl.Parent()
    startDateField := parent.FieldByName("StartDate")
    if !startDateField.IsValid() {
        return false
    }
    
    startDate := startDateField.Interface().(time.Time)
    return endDate.After(startDate)
}

validator := validation.MustNew(
    validation.WithCustomTag("after_start_date", afterStartDate),
)

type Event struct {
    StartDate time.Time `validate:"required"`
    EndDate   time.Time `validate:"required,after_start_date"`
}

Multiple Custom Tags

Register multiple tags at once:

validator := validation.MustNew(
    validation.WithCustomTag("phone", validatePhone),
    validation.WithCustomTag("username", validateUsername),
    validation.WithCustomTag("slug", validateSlug),
    validation.WithCustomTag("strong_password", validateStrongPassword),
)

Custom Validator Functions

Use WithCustomValidator for one-off validation logic:

type CreateOrderRequest struct {
    Items []OrderItem
    Total float64
}

err := validator.Validate(ctx, &req,
    validation.WithCustomValidator(func(v any) error {
        req := v.(*CreateOrderRequest)
        
        // Calculate expected total
        var sum float64
        for _, item := range req.Items {
            sum += item.Price * float64(item.Quantity)
        }
        
        // Verify total matches
        if math.Abs(req.Total-sum) > 0.01 {
            return errors.New("total does not match item prices")
        }
        
        return nil
    }),
)

Type Assertion

validation.WithCustomValidator(func(v any) error {
    req, ok := v.(*CreateUserRequest)
    if !ok {
        return errors.New("unexpected type")
    }
    
    // Validate req
    return nil
})

Returning Structured Errors

validation.WithCustomValidator(func(v any) error {
    req := v.(*CreateUserRequest)
    
    var verr validation.Error
    
    if isBlacklisted(req.Email) {
        verr.Add("email", "blacklisted", "email domain is blacklisted", nil)
    }
    
    if !isUnique(req.Username) {
        verr.Add("username", "duplicate", "username already taken", nil)
    }
    
    if verr.HasErrors() {
        return &verr
    }
    return nil
})

Field Name Mapping

Transform field names in error messages:

validator := validation.MustNew(
    validation.WithFieldNameMapper(func(name string) string {
        // Convert snake_case to Title Case
        return strings.Title(strings.ReplaceAll(name, "_", " "))
    }),
)

type User struct {
    FirstName string `json:"first_name" validate:"required"`
}

// Error message will say "First Name is required" instead of "first_name is required"

Custom Error Messages

Static Messages

validator := validation.MustNew(
    validation.WithMessages(map[string]string{
        "required": "cannot be empty",
        "email":    "invalid email format",
        "min":      "value too small",
    }),
)

Dynamic Messages

import "reflect"

validator := validation.MustNew(
    validation.WithMessageFunc("min", func(param string, kind reflect.Kind) string {
        if kind == reflect.String {
            return fmt.Sprintf("must be at least %s characters long", param)
        }
        return fmt.Sprintf("must be at least %s", param)
    }),
    validation.WithMessageFunc("max", func(param string, kind reflect.Kind) string {
        if kind == reflect.String {
            return fmt.Sprintf("must be at most %s characters long", param)
        }
        return fmt.Sprintf("must be at most %s", param)
    }),
)

Combining Custom Validators

Mix custom tags, custom validators, and built-in validation:

type CreateUserRequest struct {
    Username string `validate:"required,username"` // Custom tag
    Email    string `validate:"required,email"`    // Built-in tag
    Age      int    `validate:"required,min=18"`   // Built-in tag
}

validator := validation.MustNew(
    validation.WithCustomTag("username", validateUsername),
)

err := validator.Validate(ctx, &req,
    validation.WithCustomValidator(func(v any) error {
        req := v.(*CreateUserRequest)
        // Additional custom validation
        if isBlacklisted(req.Email) {
            return errors.New("email is blacklisted")
        }
        return nil
    }),
    validation.WithRunAll(true), // Run all strategies
)

Testing Custom Validators

Testing Custom Tags

func TestPhoneValidation(t *testing.T) {
    validator := validation.MustNew(
        validation.WithCustomTag("phone", validatePhone),
    )
    
    tests := []struct {
        name    string
        phone   string
        wantErr bool
    }{
        {"valid US", "+12345678900", false},
        {"valid international", "+441234567890", false},
        {"invalid format", "123", true},
        {"invalid prefix", "0123456789", true},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            type Test struct {
                Phone string `validate:"phone"`
            }
            
            test := Test{Phone: tt.phone}
            err := validator.Validate(context.Background(), &test)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Testing Custom Validator Functions

func TestCustomValidator(t *testing.T) {
    customValidator := func(v any) error {
        req := v.(*CreateOrderRequest)
        var sum float64
        for _, item := range req.Items {
            sum += item.Price * float64(item.Quantity)
        }
        if math.Abs(req.Total-sum) > 0.01 {
            return errors.New("total mismatch")
        }
        return nil
    }
    
    tests := []struct {
        name    string
        req     CreateOrderRequest
        wantErr bool
    }{
        {
            name: "valid total",
            req: CreateOrderRequest{
                Items: []OrderItem{{Price: 10.0, Quantity: 2}},
                Total: 20.0,
            },
            wantErr: false,
        },
        {
            name: "invalid total",
            req: CreateOrderRequest{
                Items: []OrderItem{{Price: 10.0, Quantity: 2}},
                Total: 25.0,
            },
            wantErr: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := validation.Validate(context.Background(), &tt.req,
                validation.WithCustomValidator(customValidator),
            )
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Best Practices

1. Name Tags Clearly

// Good
validation.WithCustomTag("phone", validatePhone)
validation.WithCustomTag("strong_password", validateStrongPassword)

// Bad
validation.WithCustomTag("p", validatePhone)
validation.WithCustomTag("pass", validateStrongPassword)

2. Document Custom Tags

// validatePhone validates phone numbers in E.164 format.
// Examples: +12345678900, +441234567890
func validatePhone(fl validator.FieldLevel) bool {
    return phoneRegex.MatchString(fl.Field().String())
}

3. Handle Edge Cases

func validateUsername(fl validator.FieldLevel) bool {
    username := fl.Field().String()
    
    // Handle empty strings
    if username == "" {
        return false // Or true if username is optional
    }
    
    // Check length
    if len(username) < 3 || len(username) > 20 {
        return false
    }
    
    // Check format
    return usernameRegex.MatchString(username)
}

4. Use Validator Instance for Shared Tags

// Create validator once with custom tags
var appValidator = validation.MustNew(
    validation.WithCustomTag("phone", validatePhone),
    validation.WithCustomTag("username", validateUsername),
    validation.WithCustomTag("slug", validateSlug),
)

// Reuse across handlers
func Handler1(ctx context.Context, req Request1) error {
    return appValidator.Validate(ctx, &req)
}

func Handler2(ctx context.Context, req Request2) error {
    return appValidator.Validate(ctx, &req)
}

Next Steps

9 - Security

Protect sensitive data and prevent validation attacks

The validation package includes built-in security features to protect sensitive data and prevent various attacks through validation.

Sensitive Data Redaction

Protect sensitive data in error messages with redactors:

redactor := func(path string) bool {
    return strings.Contains(path, "password") ||
           strings.Contains(path, "token") ||
           strings.Contains(path, "secret") ||
           strings.Contains(path, "api_key")
}

validator := validation.MustNew(
    validation.WithRedactor(redactor),
)

How Redaction Works

When a field is redacted, its value in error messages is replaced with [REDACTED]:

type User struct {
    Email    string `validate:"required,email"`
    Password string `validate:"required,min=8"`
}

user := User{
    Email:    "invalid",
    Password: "secret123",
}

err := validator.Validate(ctx, &user)
// Error: email: must be valid email (value: "invalid")
// Error: password: too short (value: "[REDACTED]")

Pattern-Based Redaction

func sensitiveFieldRedactor(path string) bool {
    sensitive := []string{
        "password",
        "token",
        "secret",
        "api_key",
        "credit_card",
        "ssn",
        "private_key",
    }
    
    pathLower := strings.ToLower(path)
    for _, keyword := range sensitive {
        if strings.Contains(pathLower, keyword) {
            return true
        }
    }
    return false
}

validator := validation.MustNew(
    validation.WithRedactor(sensitiveFieldRedactor),
)

Path-Specific Redaction

func pathRedactor(path string) bool {
    redactedPaths := map[string]bool{
        "user.password":          true,
        "user.security_question": true,
        "payment.card_number":    true,
        "payment.cvv":            true,
        "auth.refresh_token":     true,
    }
    return redactedPaths[path]
}

Redacting Nested Fields

type Payment struct {
    CardNumber string `json:"card_number" validate:"required,credit_card"`
    CVV        string `json:"cvv" validate:"required,len=3"`
}

type Order struct {
    Payment Payment `json:"payment"`
}

redactor := func(path string) bool {
    // Redact payment.card_number and payment.cvv
    return strings.HasPrefix(path, "payment.card_number") ||
           strings.HasPrefix(path, "payment.cvv")
}

Security Limits

Maximum Nesting Depth

The package protects against stack overflow from deeply nested structures:

// Built-in protection: max depth = 100 levels
const maxRecursionDepth = 100

This applies to:

  • ComputePresence() - Presence map computation
  • Nested struct validation
  • Recursive data structures

Maximum Fields

Limit fields processed in partial validation:

validator := validation.MustNew(
    validation.WithMaxFields(5000), // Default: 10000
)

Prevents attacks with extremely large JSON objects:

{
  "field1": "value",
  "field2": "value",
  // ... 100,000 more fields
}

Maximum Errors

Limit errors returned to prevent memory exhaustion:

validator := validation.MustNew(
    validation.WithMaxErrors(100), // Default: unlimited
)

When limit is reached, Truncated flag is set:

var verr *validation.Error
if errors.As(err, &verr) {
    if verr.Truncated {
        log.Warn("more validation errors exist (truncated)")
    }
}

Schema Cache Size

Limit JSON Schema cache to prevent memory exhaustion:

validator := validation.MustNew(
    validation.WithMaxCachedSchemas(2048), // Default: 1024
)

Uses LRU eviction - oldest schemas are removed when limit is reached.

Input Validation Security

Prevent Injection Attacks

Always validate input format before using in queries or commands:

type SearchRequest struct {
    Query string `validate:"required,max=100,alphanum"`
}

// Safe from SQL injection (alphanumeric only)
err := validator.Validate(ctx, &req)

Sanitize HTML

import "html"

type CreatePostRequest struct {
    Title   string `validate:"required,max=200"`
    Content string `validate:"required,max=10000"`
}

func (r *CreatePostRequest) Validate() error {
    // Sanitize HTML
    r.Title = html.EscapeString(r.Title)
    r.Content = html.EscapeString(r.Content)
    return nil
}

Validate File Paths

import "path/filepath"

type UploadRequest struct {
    Filename string `validate:"required"`
}

func (r *UploadRequest) Validate() error {
    // Prevent path traversal attacks
    cleaned := filepath.Clean(r.Filename)
    if strings.Contains(cleaned, "..") {
        return errors.New("invalid filename: path traversal detected")
    }
    r.Filename = cleaned
    return nil
}

Rate Limiting

Combine validation with rate limiting to prevent abuse:

import "golang.org/x/time/rate"

var limiter = rate.NewLimiter(rate.Every(time.Second), 10)

func ValidateWithRateLimit(ctx context.Context, req any) error {
    // Check rate limit first (fast)
    if !limiter.Allow() {
        return errors.New("rate limit exceeded")
    }
    
    // Then validate (slower)
    return validation.Validate(ctx, req)
}

Denial of Service Prevention

Request Size Limits

func Handler(w http.ResponseWriter, r *http.Request) {
    // Limit request body size
    r.Body = http.MaxBytesReader(w, r.Body, 1*1024*1024) // 1MB max
    
    rawJSON, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
        return
    }
    
    // Continue with validation
}

Array/Slice Limits

type BatchRequest struct {
    Items []Item `validate:"required,min=1,max=100"`
}

// Prevents DoS with extremely large arrays

String Length Limits

type Request struct {
    Description string `validate:"max=10000"`
}

// Prevents memory exhaustion from huge strings

Validation Timeout

import "context"

func ValidateWithTimeout(req any) error {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    return validation.Validate(ctx, req)
}

Security Best Practices

1. Always Validate User Input

// Good
func CreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    json.NewDecoder(r.Body).Decode(&req)
    
    // ALWAYS validate
    if err := validation.Validate(r.Context(), &req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Safe to use req
}

// Bad
func CreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    json.NewDecoder(r.Body).Decode(&req)
    
    // Using unvalidated input - DANGEROUS!
    db.Exec("INSERT INTO users VALUES (?, ?)", req.Username, req.Email)
}

2. Validate Before Database Operations

func UpdateUser(ctx context.Context, req UpdateUserRequest) error {
    // Validate first
    if err := validation.Validate(ctx, &req); err != nil {
        return err
    }
    
    // Then update database
    return db.UpdateUser(ctx, req)
}

3. Use Strict Mode for APIs

validator := validation.MustNew(
    validation.WithDisallowUnknownFields(true),
)

// Rejects requests with unexpected fields (typo detection)

4. Redact All Sensitive Fields

func comprehensiveRedactor(path string) bool {
    pathLower := strings.ToLower(path)
    
    // Passwords and secrets
    if strings.Contains(pathLower, "password") ||
       strings.Contains(pathLower, "secret") ||
       strings.Contains(pathLower, "token") ||
       strings.Contains(pathLower, "key") {
        return true
    }
    
    // Payment information
    if strings.Contains(pathLower, "card") ||
       strings.Contains(pathLower, "cvv") ||
       strings.Contains(pathLower, "credit") {
        return true
    }
    
    // Personal information
    if strings.Contains(pathLower, "ssn") ||
       strings.Contains(pathLower, "social_security") ||
       strings.Contains(pathLower, "tax_id") {
        return true
    }
    
    return false
}

5. Log Validation Failures

err := validation.Validate(ctx, &req)
if err != nil {
    var verr *validation.Error
    if errors.As(err, &verr) {
        // Log validation failures for security monitoring
        log.With(
            "error_count", len(verr.Fields),
            "fields", getFieldPaths(verr.Fields),
            "ip", getClientIP(r),
        ).Warn("validation failed")
    }
    
    return err
}

6. Fail Secure

// Good - fail if validation library has issues
validator, err := validation.New(options...)
if err != nil {
    panic("failed to create validator: " + err.Error())
}

// Bad - continue without validation
validator, err := validation.New(options...)
if err != nil {
    log.Warn("validator creation failed, continuing anyway") // DANGEROUS
}

Common Security Vulnerabilities

SQL Injection

// VULNERABLE
type SearchRequest struct {
    Query string // No validation
}
db.Exec("SELECT * FROM users WHERE name = '" + req.Query + "'")

// SAFE
type SearchRequest struct {
    Query string `validate:"required,max=100,alphanum"`
}
if err := validation.Validate(ctx, &req); err != nil {
    return err
}
db.Exec("SELECT * FROM users WHERE name = ?", req.Query)

Path Traversal

// VULNERABLE
type FileRequest struct {
    Path string // No validation
}
os.ReadFile(req.Path) // Could be "../../etc/passwd"

// SAFE
type FileRequest struct {
    Path string `validate:"required,max=255"`
}

func (r *FileRequest) Validate() error {
    cleaned := filepath.Clean(r.Path)
    if strings.Contains(cleaned, "..") {
        return errors.New("path traversal detected")
    }
    if !strings.HasPrefix(cleaned, "/safe/directory/") {
        return errors.New("path outside allowed directory")
    }
    return nil
}

XXE (XML External Entity)

// VULNERABLE
xml.Unmarshal(req.Body, &data)

// SAFE
decoder := xml.NewDecoder(req.Body)
decoder.Strict = true
decoder.Entity = xml.HTMLEntity // Prevent external entities
err := decoder.Decode(&data)

Mass Assignment

// VULNERABLE
type UpdateUserRequest struct {
    Email string
    Role  string // User shouldn't be able to set this!
}

// SAFE - separate request types
type UpdateUserRequest struct {
    Email string `validate:"required,email"`
}

type AdminUpdateUserRequest struct {
    Email string `validate:"required,email"`
    Role  string `validate:"required,oneof=user admin"`
}

Security Checklist

  • All user input is validated before use
  • Sensitive fields are redacted in errors
  • Request size limits are enforced
  • Array/slice lengths are limited
  • Nesting depth is limited (handled automatically)
  • Unknown fields are rejected in strict mode
  • Validation failures are logged
  • Rate limiting is implemented
  • Timeouts are set for validation
  • SQL queries use parameterized statements
  • File paths are sanitized
  • HTML is escaped before rendering
  • Mass assignment is prevented

Next Steps

10 - Examples

Real-world validation patterns and integration examples

Complete examples showing how to use the validation package in real-world scenarios.

Basic REST API

Create User Endpoint

package main

import (
    "context"
    "encoding/json"
    "net/http"
    
    "rivaas.dev/validation"
)

type CreateUserRequest struct {
    Username string `json:"username" validate:"required,min=3,max=20,alphanum"`
    Email    string `json:"email" validate:"required,email"`
    Age      int    `json:"age" validate:"required,min=18,max=120"`
    Password string `json:"password" validate:"required,min=8"`
}

var validator = validation.MustNew(
    validation.WithRedactor(func(path string) bool {
        return strings.Contains(path, "password")
    }),
    validation.WithMaxErrors(10),
)

func CreateUser(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    
    if err := validator.Validate(ctx, &req); err != nil {
        handleValidationError(w, err)
        return
    }
    
    // Create user
    user, err := createUser(ctx, req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

func handleValidationError(w http.ResponseWriter, err error) {
    var verr *validation.Error
    if !errors.As(err, &verr) {
        http.Error(w, "validation failed", http.StatusBadRequest)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusUnprocessableEntity)
    json.NewEncoder(w).Encode(map[string]any{
        "error":  "validation_failed",
        "fields": verr.Fields,
    })
}

Update User Endpoint (PATCH)

type UpdateUserRequest struct {
    Email *string `json:"email" validate:"omitempty,email"`
    Age   *int    `json:"age" validate:"omitempty,min=18,max=120"`
}

func UpdateUser(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    // Read raw body
    rawJSON, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "failed to read body", http.StatusBadRequest)
        return
    }
    
    // Compute which fields are present
    presence, err := validation.ComputePresence(rawJSON)
    if err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    
    // Parse into struct
    var req UpdateUserRequest
    if err := json.Unmarshal(rawJSON, &req); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    
    // Validate only present fields
    if err := validation.ValidatePartial(ctx, &req, presence); err != nil {
        handleValidationError(w, err)
        return
    }
    
    // Update user
    userID := r.PathValue("id")
    if err := updateUser(ctx, userID, req, presence); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.WriteHeader(http.StatusOK)
}

Custom Validation Methods

Order Validation

type CreateOrderRequest struct {
    UserID    int         `json:"user_id"`
    Items     []OrderItem `json:"items"`
    CouponCode string     `json:"coupon_code"`
    Total     float64     `json:"total"`
}

type OrderItem struct {
    ProductID int     `json:"product_id" validate:"required"`
    Quantity  int     `json:"quantity" validate:"required,min=1"`
    Price     float64 `json:"price" validate:"required,min=0"`
}

func (r *CreateOrderRequest) ValidateContext(ctx context.Context) error {
    var verr validation.Error
    
    // Validate user exists
    if !userExists(ctx, r.UserID) {
        verr.Add("user_id", "not_found", "user does not exist", nil)
    }
    
    // Validate items
    if len(r.Items) == 0 {
        verr.Add("items", "required", "at least one item required", nil)
    }
    
    var calculatedTotal float64
    for i, item := range r.Items {
        // Validate product and price
        product, err := getProduct(ctx, item.ProductID)
        if err != nil {
            verr.Add(
                fmt.Sprintf("items.%d.product_id", i),
                "not_found",
                "product does not exist",
                nil,
            )
            continue
        }
        
        if item.Price != product.Price {
            verr.Add(
                fmt.Sprintf("items.%d.price", i),
                "mismatch",
                "price does not match current product price",
                map[string]any{
                    "expected": product.Price,
                    "actual":   item.Price,
                },
            )
        }
        
        calculatedTotal += item.Price * float64(item.Quantity)
    }
    
    // Validate coupon
    if r.CouponCode != "" {
        discount, err := validateCoupon(ctx, r.CouponCode)
        if err != nil {
            verr.Add("coupon_code", "invalid", err.Error(), nil)
        } else {
            calculatedTotal -= discount
        }
    }
    
    // Validate total
    if math.Abs(r.Total-calculatedTotal) > 0.01 {
        verr.Add(
            "total",
            "mismatch",
            "total does not match calculated amount",
            map[string]any{
                "expected": calculatedTotal,
                "actual":   r.Total,
            },
        )
    }
    
    if verr.HasErrors() {
        return &verr
    }
    return nil
}

Custom Validation Tags

Application Validator

package app

import (
    "regexp"
    "unicode"
    
    "github.com/go-playground/validator/v10"
    "rivaas.dev/validation"
)

var (
    phoneRegex    = regexp.MustCompile(`^\+?[1-9]\d{1,14}$`)
    usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`)
)

var Validator = validation.MustNew(
    // Custom tags
    validation.WithCustomTag("phone", validatePhone),
    validation.WithCustomTag("username", validateUsername),
    validation.WithCustomTag("strong_password", validateStrongPassword),
    
    // Security
    validation.WithRedactor(sensitiveFieldRedactor),
    validation.WithMaxErrors(20),
    
    // Custom messages
    validation.WithMessages(map[string]string{
        "required": "is required",
        "email":    "must be a valid email address",
    }),
)

func validatePhone(fl validator.FieldLevel) bool {
    return phoneRegex.MatchString(fl.Field().String())
}

func validateUsername(fl validator.FieldLevel) bool {
    return usernameRegex.MatchString(fl.Field().String())
}

func validateStrongPassword(fl validator.FieldLevel) bool {
    password := fl.Field().String()
    
    if len(password) < 8 {
        return false
    }
    
    var hasUpper, hasLower, hasDigit, hasSpecial bool
    for _, c := range password {
        switch {
        case unicode.IsUpper(c):
            hasUpper = true
        case unicode.IsLower(c):
            hasLower = true
        case unicode.IsDigit(c):
            hasDigit = true
        case unicode.IsPunct(c) || unicode.IsSymbol(c):
            hasSpecial = true
        }
    }
    
    return hasUpper && hasLower && hasDigit && hasSpecial
}

func sensitiveFieldRedactor(path string) bool {
    pathLower := strings.ToLower(path)
    return strings.Contains(pathLower, "password") ||
           strings.Contains(pathLower, "token") ||
           strings.Contains(pathLower, "secret") ||
           strings.Contains(pathLower, "card") ||
           strings.Contains(pathLower, "cvv")
}

Using Custom Tags

type Registration struct {
    Username string `validate:"required,username"`
    Phone    string `validate:"required,phone"`
    Password string `validate:"required,strong_password"`
}

func Register(w http.ResponseWriter, r *http.Request) {
    var req Registration
    json.NewDecoder(r.Body).Decode(&req)
    
    if err := app.Validator.Validate(r.Context(), &req); err != nil {
        handleValidationError(w, err)
        return
    }
    
    // Process registration
}

JSON Schema Validation

type Product struct {
    Name        string  `json:"name"`
    Price       float64 `json:"price"`
    Category    string  `json:"category"`
    InStock     bool    `json:"in_stock"`
    Tags        []string `json:"tags"`
}

func (p Product) JSONSchema() (id, schema string) {
    return "product-v1", `{
        "$schema": "http://json-schema.org/draft-07/schema#",
        "type": "object",
        "properties": {
            "name": {
                "type": "string",
                "minLength": 1,
                "maxLength": 100
            },
            "price": {
                "type": "number",
                "minimum": 0,
                "exclusiveMinimum": true
            },
            "category": {
                "type": "string",
                "enum": ["electronics", "clothing", "books", "food"]
            },
            "in_stock": {
                "type": "boolean"
            },
            "tags": {
                "type": "array",
                "items": {"type": "string"},
                "minItems": 1,
                "maxItems": 10,
                "uniqueItems": true
            }
        },
        "required": ["name", "price", "category"],
        "additionalProperties": false
    }`
}

func CreateProduct(w http.ResponseWriter, r *http.Request) {
    var product Product
    json.NewDecoder(r.Body).Decode(&product)
    
    // Validates using JSON Schema
    if err := validation.Validate(r.Context(), &product); err != nil {
        handleValidationError(w, err)
        return
    }
    
    // Save product
}

Multi-Strategy Validation

type User struct {
    Email    string `json:"email" validate:"required,email"`
    Username string `json:"username" validate:"required,min=3,max=20"`
}

// Add JSON Schema
func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{
        "type": "object",
        "properties": {
            "email": {"type": "string", "format": "email"},
            "username": {"type": "string", "minLength": 3, "maxLength": 20}
        }
    }`
}

// Add custom validation
func (u *User) ValidateContext(ctx context.Context) error {
    // Check username uniqueness
    if usernameExists(ctx, u.Username) {
        return errors.New("username already taken")
    }
    return nil
}

// Run all strategies
err := validation.Validate(ctx, &user,
    validation.WithRunAll(true),
)

Testing

Testing Validation

func TestUserValidation(t *testing.T) {
    tests := []struct {
        name    string
        user    CreateUserRequest
        wantErr bool
        errCode string
    }{
        {
            name: "valid user",
            user: CreateUserRequest{
                Username: "alice",
                Email:    "alice@example.com",
                Age:      25,
                Password: "SecurePass123!",
            },
            wantErr: false,
        },
        {
            name: "invalid email",
            user: CreateUserRequest{
                Username: "alice",
                Email:    "invalid",
                Age:      25,
                Password: "SecurePass123!",
            },
            wantErr: true,
            errCode: "tag.email",
        },
        {
            name: "underage",
            user: CreateUserRequest{
                Username: "alice",
                Email:    "alice@example.com",
                Age:      15,
                Password: "SecurePass123!",
            },
            wantErr: true,
            errCode: "tag.min",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := validator.Validate(context.Background(), &tt.user)
            
            if tt.wantErr {
                if err == nil {
                    t.Error("expected error, got nil")
                    return
                }
                
                var verr *validation.Error
                if !errors.As(err, &verr) {
                    t.Error("expected validation.Error")
                    return
                }
                
                if tt.errCode != "" && !verr.HasCode(tt.errCode) {
                    t.Errorf("expected error code %s", tt.errCode)
                }
            } else {
                if err != nil {
                    t.Errorf("unexpected error: %v", err)
                }
            }
        })
    }
}

Testing Partial Validation

func TestPartialValidation(t *testing.T) {
    tests := []struct {
        name    string
        json    string
        wantErr bool
    }{
        {
            name:    "valid email update",
            json:    `{"email": "new@example.com"}`,
            wantErr: false,
        },
        {
            name:    "invalid email update",
            json:    `{"email": "invalid"}`,
            wantErr: true,
        },
        {
            name:    "valid age update",
            json:    `{"age": 25}`,
            wantErr: false,
        },
        {
            name:    "underage update",
            json:    `{"age": 15}`,
            wantErr: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            presence, _ := validation.ComputePresence([]byte(tt.json))
            
            var req UpdateUserRequest
            json.Unmarshal([]byte(tt.json), &req)
            
            err := validation.ValidatePartial(context.Background(), &req, presence)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidatePartial() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Integration with rivaas/router

import "rivaas.dev/router"

func Handler(c *router.Context) error {
    var req CreateUserRequest
    if err := c.BindJSON(&req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{
            "error": "invalid JSON",
        })
    }
    
    if err := validator.Validate(c.Request().Context(), &req); err != nil {
        var verr *validation.Error
        if errors.As(err, &verr) {
            return c.JSON(http.StatusUnprocessableEntity, map[string]any{
                "error":  "validation_failed",
                "fields": verr.Fields,
            })
        }
        return err
    }
    
    // Process request
    return c.JSON(http.StatusOK, createUser(req))
}

Integration with rivaas/app

import "rivaas.dev/app"

func Handler(c *app.Context) error {
    var req CreateUserRequest
    if err := c.Bind(&req); err != nil {
        return err // Automatically handled
    }
    
    // Validation happens automatically with app.Context
    // But you can also validate manually:
    if err := validator.Validate(c.Context(), &req); err != nil {
        return err // Automatically converted to proper HTTP response
    }
    
    return c.JSON(http.StatusOK, createUser(req))
}

Performance Tips

Reuse Validator Instances

// Good - create once
var appValidator = validation.MustNew(
    validation.WithMaxErrors(10),
)

func Handler1(ctx context.Context, req Request1) error {
    return appValidator.Validate(ctx, &req)
}

func Handler2(ctx context.Context, req Request2) error {
    return appValidator.Validate(ctx, &req)
}

// Bad - create every time (slow)
func Handler(ctx context.Context, req Request) error {
    validator := validation.MustNew()
    return validator.Validate(ctx, &req)
}

Use Package-Level Functions for Simple Cases

// Simple validation - use package-level function
err := validation.Validate(ctx, &req)

// Complex validation - create validator instance
validator := validation.MustNew(
    validation.WithCustomTag("phone", validatePhone),
    validation.WithRedactor(redactor),
)
err := validator.Validate(ctx, &req)

Next Steps