veza/REQUEST_RESPONSE_VALIDATION_GUIDE.md

14 KiB

Request/Response Validation Guide

INT-012: Add request/response validation

Date: 2025-12-25
Status: Completed

Overview

This guide provides comprehensive documentation for request and response validation in the Veza Backend API. It covers validation strategies, best practices, and implementation guidelines.

Current Implementation

The Veza API uses a centralized validation system built on:

  • go-playground/validator: Core validation library
  • Gin binding: Request binding and basic validation
  • Custom validators: Domain-specific validation rules
  • Centralized helpers: BindAndValidateJSON for consistent validation

Validation Architecture

Components

  1. Validator (internal/validators/validator.go)

    • Centralized validator instance
    • Custom validation rules
    • Error message formatting
  2. Common Helpers (internal/common/validation.go)

    • BindAndValidateJSON: Main validation helper
    • Error handling
    • Body size limits
  3. Middleware (internal/middleware/validation.go)

    • Query parameter validation
    • Request-level validation
  4. DTOs (Data Transfer Objects)

    • Request structures with validation tags
    • Response structures (when needed)

Request Validation

Using BindAndValidateJSON

The recommended way to validate requests:

func (h *Handler) CreateResource(c *gin.Context) {
    var req CreateResourceRequest
    
    // Validate request
    if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
        RespondWithAppError(c, appErr)
        return
    }
    
    // Process validated request
    // ...
}

Validation Tags

Standard Tags (go-playground/validator)

type CreateUserRequest struct {
    // Required fields
    Email    string `json:"email" binding:"required,email" validate:"required,email"`
    Username string `json:"username" binding:"required,min=3,max=30" validate:"required,min=3,max=30,username"`
    
    // Optional fields
    Bio      string `json:"bio" binding:"omitempty,max=500" validate:"omitempty,max=500"`
    
    // Numeric validation
    Age      int    `json:"age" binding:"omitempty,min=18,max=120" validate:"omitempty,min=18,max=120"`
    
    // UUID validation
    UserID   string `json:"user_id" binding:"required,uuid" validate:"required,uuid"`
    
    // Enum validation
    Role     string `json:"role" binding:"required,oneof=user admin moderator" validate:"required,oneof=user admin moderator"`
    
    // URL validation
    Website  string `json:"website" binding:"omitempty,url" validate:"omitempty,url"`
}

Custom Tags

The API includes custom validation tags:

  • username: Alphanumeric + underscore, 3-30 characters
  • uuid_string: UUID format validation
  • slug: URL-friendly slug format
  • phone: International phone number format
  • date_iso: ISO 8601 date format (YYYY-MM-DD)
  • not_empty: Non-empty string after trim
type UpdateProfileRequest struct {
    Username string `json:"username" validate:"required,username"`
    Slug     string `json:"slug" validate:"omitempty,slug"`
    Phone    string `json:"phone" validate:"omitempty,phone"`
    BirthDate string `json:"birth_date" validate:"omitempty,date_iso"`
}

Common Validation Patterns

Required Fields

Field string `json:"field" binding:"required" validate:"required"`

String Length

Title string `json:"title" binding:"required,min=1,max=200" validate:"required,min=1,max=200"`

Numeric Ranges

Price float64 `json:"price" binding:"required,min=0,gt=0" validate:"required,min=0,gt=0"`
Page  int     `json:"page" binding:"omitempty,min=1" validate:"omitempty,min=1"`

Enums

Status string `json:"status" binding:"required,oneof=active inactive pending" validate:"required,oneof=active inactive pending"`

UUIDs

TrackID string `json:"track_id" binding:"required,uuid" validate:"required,uuid"`

Optional Fields

Description string `json:"description" binding:"omitempty,max=1000" validate:"omitempty,max=1000"`

Query Parameter Validation

Use the query parameter validation middleware:

queryValidation := middleware.NewQueryParamValidation(logger)

router.GET("/tracks", 
    queryValidation.ValidateQueryParams(map[string]string{
        "page":  "numeric,min=1",
        "limit": "numeric,min=1,max=100",
        "sort":  "oneof=asc,desc",
    }),
    handler.ListTracks,
)

Path Parameter Validation

Validate path parameters manually:

func (h *Handler) GetTrack(c *gin.Context) {
    trackID := c.Param("id")
    
    // Validate UUID format
    if _, err := uuid.Parse(trackID); err != nil {
        RespondWithAppError(c, apperrors.NewValidationError("Invalid track ID format"))
        return
    }
    
    // Continue processing
}

Response Validation

When to Validate Responses

Response validation is typically not needed in production, but can be useful for:

  • Development/testing
  • API contract testing
  • Ensuring consistency

Response Structure Validation

Ensure responses follow the standard format:

// Standard success response
RespondSuccess(c, http.StatusOK, gin.H{
    "data": result,
})

// Standard error response
RespondWithAppError(c, apperrors.NewValidationError("Validation failed"))

Response Schema Validation (Optional)

For strict response validation, you can validate response structures:

type TrackResponse struct {
    ID       string `json:"id" validate:"required,uuid"`
    Title    string `json:"title" validate:"required,min=1,max=200"`
    Duration int    `json:"duration" validate:"required,min=0"`
}

func validateResponse(data interface{}) error {
    validator := validators.NewValidator()
    errors := validator.Validate(data)
    if len(errors) > 0 {
        return fmt.Errorf("response validation failed: %v", errors)
    }
    return nil
}

Validation Error Format

Standard Error Response

All validation errors follow the standard API response format:

{
  "success": false,
  "error": {
    "code": 1000,
    "message": "Validation failed",
    "details": [
      {
        "field": "email",
        "message": "The field 'email' must be a valid email address",
        "value": "invalid-email"
      },
      {
        "field": "username",
        "message": "The field 'username' must be at least 3 characters long",
        "value": "ab"
      }
    ],
    "timestamp": "2025-12-25T10:30:00Z",
    "request_id": "550e8400-e29b-41d4-a716-446655440000"
  }
}

Error Codes

  • 1000 (ErrCodeValidation): General validation error
  • 1001: Required field missing
  • 1002: Invalid format
  • 1003: Value out of range

Best Practices

1. Always Validate Input

// ✅ Good
var req CreateRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
    RespondWithAppError(c, appErr)
    return
}

// ❌ Bad
var req CreateRequest
c.ShouldBindJSON(&req) // No validation, no error handling

2. Use Both Binding and Validation Tags

// ✅ Good
Email string `json:"email" binding:"required,email" validate:"required,email"`

// ⚠️ Acceptable (but less robust)
Email string `json:"email" binding:"required,email"`

3. Validate Path Parameters

// ✅ Good
trackID := c.Param("id")
if _, err := uuid.Parse(trackID); err != nil {
    RespondWithAppError(c, apperrors.NewValidationError("Invalid track ID"))
    return
}

// ❌ Bad
trackID := c.Param("id") // No validation

4. Validate Query Parameters

// ✅ Good
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil || page < 1 {
    RespondWithAppError(c, apperrors.NewValidationError("Invalid page number"))
    return
}

// ✅ Better (using middleware)
queryValidation.ValidateQueryParams(map[string]string{
    "page": "numeric,min=1",
})

5. Provide Clear Error Messages

// ✅ Good - Custom validator with clear messages
Username string `json:"username" validate:"required,username"`

// ❌ Bad - Generic error
Username string `json:"username" validate:"required"`

6. Set Appropriate Limits

// ✅ Good - Reasonable limits
Title string `json:"title" validate:"required,min=1,max=200"`

// ❌ Bad - No limits or unrealistic limits
Title string `json:"title" validate:"required"`

7. Validate Nested Structures

type CreatePlaylistRequest struct {
    Title  string              `json:"title" validate:"required,min=1,max=200"`
    Tracks []TrackReference    `json:"tracks" validate:"omitempty,dive"`
}

type TrackReference struct {
    ID     string `json:"id" validate:"required,uuid"`
    Position int  `json:"position" validate:"omitempty,min=0"`
}

Common Validation Scenarios

User Registration

type RegisterRequest struct {
    Email           string `json:"email" binding:"required,email" validate:"required,email"`
    Username        string `json:"username" binding:"required,min=3,max=30" validate:"required,min=3,max=30,username"`
    Password        string `json:"password" binding:"required,min=12" validate:"required,min=12"`
    PasswordConfirm string `json:"password_confirm" binding:"required,eqfield=Password" validate:"required,eqfield=Password"`
}

Pagination

type PaginationParams struct {
    Page  int `json:"page" binding:"omitempty,min=1" validate:"omitempty,min=1"`
    Limit int `json:"limit" binding:"omitempty,min=1,max=100" validate:"omitempty,min=1,max=100"`
}

File Upload

type UploadRequest struct {
    Filename string `json:"filename" binding:"required,min=1,max=255" validate:"required,min=1,max=255"`
    FileSize int64  `json:"file_size" binding:"required,min=1,max=10737418240" validate:"required,min=1,max=10737418240"` // 10GB max
    MimeType string `json:"mime_type" binding:"required" validate:"required"`
}

Date Ranges

type DateRangeRequest struct {
    StartDate string `json:"start_date" binding:"omitempty,date_iso" validate:"omitempty,date_iso"`
    EndDate   string `json:"end_date" binding:"omitempty,date_iso" validate:"omitempty,date_iso"`
}

Testing Validation

Unit Tests

func TestCreateUserRequest_Validation(t *testing.T) {
    validator := validators.NewValidator()
    
    tests := []struct {
        name    string
        request CreateUserRequest
        wantErr bool
    }{
        {
            name: "valid request",
            request: CreateUserRequest{
                Email:    "user@example.com",
                Username: "testuser",
            },
            wantErr: false,
        },
        {
            name: "invalid email",
            request: CreateUserRequest{
                Email:    "invalid-email",
                Username: "testuser",
            },
            wantErr: true,
        },
        {
            name: "username too short",
            request: CreateUserRequest{
                Email:    "user@example.com",
                Username: "ab",
            },
            wantErr: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            errors := validator.Validate(tt.request)
            if (len(errors) > 0) != tt.wantErr {
                t.Errorf("Validation error mismatch: got %v, want %v", errors, tt.wantErr)
            }
        })
    }
}

Integration Tests

func TestCreateUser_Validation(t *testing.T) {
    router := setupTestRouter()
    
    tests := []struct {
        name           string
        body           string
        expectedStatus int
    }{
        {
            name:           "missing email",
            body:           `{"username": "testuser"}`,
            expectedStatus: http.StatusBadRequest,
        },
        {
            name:           "invalid email format",
            body:           `{"email": "invalid", "username": "testuser"}`,
            expectedStatus: http.StatusBadRequest,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            w := httptest.NewRecorder()
            req := httptest.NewRequest("POST", "/api/v1/users", strings.NewReader(tt.body))
            router.ServeHTTP(w, req)
            
            assert.Equal(t, tt.expectedStatus, w.Code)
        })
    }
}

Validation Checklist

When creating a new endpoint:

  • Define request DTO with validation tags
  • Use BindAndValidateJSON for request validation
  • Validate path parameters (UUIDs, IDs)
  • Validate query parameters (pagination, filters)
  • Test validation with invalid inputs
  • Test validation with edge cases
  • Ensure error messages are clear
  • Document validation rules in Swagger annotations

Common Issues and Solutions

Issue: Validation not working

Solution: Ensure both binding and validate tags are present:

// ✅ Correct
Email string `json:"email" binding:"required,email" validate:"required,email"`

Issue: Custom validator not recognized

Solution: Ensure custom validators are registered in registerCustomValidations:

v.RegisterValidation("custom_tag", func(fl validator.FieldLevel) bool {
    // Validation logic
    return true
})

Issue: Nested struct validation

Solution: Use dive tag for nested structures:

Tracks []TrackReference `json:"tracks" validate:"omitempty,dive"`

Issue: Conditional validation

Solution: Use custom validator or validate in handler:

// In handler
if req.Type == "premium" && req.Price < 10 {
    RespondWithAppError(c, apperrors.NewValidationError("Premium products must cost at least $10"))
    return
}

References


Last Updated: 2025-12-25
Maintained By: Veza Backend Team