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:
- Installation - Get started with the validation package
- Basic Usage - Learn the fundamentals of validation
- Struct Tags - Use go-playground/validator struct tags
- JSON Schema - Validate with JSON Schema
- Custom Interfaces - Implement Validate() methods
- Partial Validation - Handle PATCH requests correctly
- Error Handling - Work with structured errors
- Custom Validators - Register custom tags and functions
- Security - Protect sensitive data and prevent attacks
- Examples - Real-world integration patterns
Validation Strategies
The package supports three validation strategies that can be used individually or combined:
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:
- Interface methods (
Validate() / ValidateContext()) - Struct tags (
validate:"...") - 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
| Feature | rivaas.dev/validation | go-playground/validator | JSON Schema validators |
|---|
| Struct tags | ✅ | ✅ | ❌ |
| JSON Schema | ✅ | ❌ | ✅ |
| Custom interfaces | ✅ | ❌ | ❌ |
| Partial validation | ✅ | ❌ | ❌ |
| Multi-strategy | ✅ | ❌ | ❌ |
| Context support | ✅ | ❌ | Varies |
| Built-in redaction | ✅ | ❌ | ❌ |
| Thread-safe | ✅ | ✅ | Varies |
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:
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:
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.
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
| Tag | Description |
|---|
min=N | Minimum value (numbers) or length (strings/slices) |
max=N | Maximum value (numbers) or length (strings/slices) |
eq=N | Equal to N |
ne=N | Not equal to N |
gt=N | Greater than N |
gte=N | Greater than or equal to N |
lt=N | Less than N |
lte=N | Less 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.
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:
| Tag | Description |
|---|
eqfield=Field | Must equal another field |
nefield=Field | Must not equal another field |
gtfield=Field | Must be greater than another field |
ltfield=Field | Must be less than another field |
required_if=Field Value | Required when field equals value |
required_unless=Field Value | Required unless field equals value |
required_with=Field | Required when field is present |
required_without=Field | Required when field is absent |
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.
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
- 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"]
}`
}
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 addressuri / url - URLhostname - DNS hostnameipv4 / ipv6 - IP addressesdate - Date (YYYY-MM-DD)date-time - RFC3339 date-timeuuid - 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 mismatchschema.required - Missing required fieldschema.minimum - Below minimum valueschema.pattern - Pattern mismatchschema.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:
ValidateContext(ctx) or Validate() (highest)- Struct tags (
validate:"...") - 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:
Pointer Receiver (Recommended)
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)
}
// 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)
}
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 validationage 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)
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")
}
}
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 errorsactual - 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.
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
}
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"`
}
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"`
}
// 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"`
}
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
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
// Good
validation.WithCustomTag("phone", validatePhone)
validation.WithCustomTag("strong_password", validateStrongPassword)
// Bad
validation.WithCustomTag("p", validatePhone)
validation.WithCustomTag("pass", validateStrongPassword)
// 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)
}
// 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.
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
// 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
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
}
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")
}
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))
}
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