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:
BindAndValidateJSONfor consistent validation
Validation Architecture
Components
-
Validator (
internal/validators/validator.go)- Centralized validator instance
- Custom validation rules
- Error message formatting
-
Common Helpers (
internal/common/validation.go)BindAndValidateJSON: Main validation helper- Error handling
- Body size limits
-
Middleware (
internal/middleware/validation.go)- Query parameter validation
- Request-level validation
-
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 charactersuuid_string: UUID format validationslug: URL-friendly slug formatphone: International phone number formatdate_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
BindAndValidateJSONfor 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
- go-playground/validator Documentation
- Gin Binding Documentation
internal/validators/validator.go- Custom validatorsinternal/common/validation.go- Validation helpersERROR_RESPONSE_STANDARD.md- Error response format
Last Updated: 2025-12-25
Maintained By: Veza Backend Team