[INT-012] int: Add request/response validation
This commit is contained in:
parent
0bd12aa91d
commit
617c3f577e
2 changed files with 555 additions and 3 deletions
549
REQUEST_RESPONSE_VALIDATION_GUIDE.md
Normal file
549
REQUEST_RESPONSE_VALIDATION_GUIDE.md
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
# 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:
|
||||
|
||||
```go
|
||||
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)
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
Field string `json:"field" binding:"required" validate:"required"`
|
||||
```
|
||||
|
||||
#### String Length
|
||||
|
||||
```go
|
||||
Title string `json:"title" binding:"required,min=1,max=200" validate:"required,min=1,max=200"`
|
||||
```
|
||||
|
||||
#### Numeric Ranges
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
Status string `json:"status" binding:"required,oneof=active inactive pending" validate:"required,oneof=active inactive pending"`
|
||||
```
|
||||
|
||||
#### UUIDs
|
||||
|
||||
```go
|
||||
TrackID string `json:"track_id" binding:"required,uuid" validate:"required,uuid"`
|
||||
```
|
||||
|
||||
#### Optional Fields
|
||||
|
||||
```go
|
||||
Description string `json:"description" binding:"omitempty,max=1000" validate:"omitempty,max=1000"`
|
||||
```
|
||||
|
||||
### Query Parameter Validation
|
||||
|
||||
Use the query parameter validation middleware:
|
||||
|
||||
```go
|
||||
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:
|
||||
|
||||
```go
|
||||
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:
|
||||
|
||||
```go
|
||||
// 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:
|
||||
|
||||
```go
|
||||
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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
|
||||
```go
|
||||
// ✅ 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
|
||||
|
||||
```go
|
||||
// ✅ 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
|
||||
|
||||
```go
|
||||
// ✅ 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
|
||||
|
||||
```go
|
||||
// ✅ 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
|
||||
|
||||
```go
|
||||
// ✅ 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
|
||||
|
||||
```go
|
||||
// ✅ 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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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:
|
||||
|
||||
```go
|
||||
// ✅ Correct
|
||||
Email string `json:"email" binding:"required,email" validate:"required,email"`
|
||||
```
|
||||
|
||||
### Issue: Custom validator not recognized
|
||||
|
||||
**Solution**: Ensure custom validators are registered in `registerCustomValidations`:
|
||||
|
||||
```go
|
||||
v.RegisterValidation("custom_tag", func(fl validator.FieldLevel) bool {
|
||||
// Validation logic
|
||||
return true
|
||||
})
|
||||
```
|
||||
|
||||
### Issue: Nested struct validation
|
||||
|
||||
**Solution**: Use `dive` tag for nested structures:
|
||||
|
||||
```go
|
||||
Tracks []TrackReference `json:"tracks" validate:"omitempty,dive"`
|
||||
```
|
||||
|
||||
### Issue: Conditional validation
|
||||
|
||||
**Solution**: Use custom validator or validate in handler:
|
||||
|
||||
```go
|
||||
// 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](https://pkg.go.dev/github.com/go-playground/validator/v10)
|
||||
- [Gin Binding Documentation](https://gin-gonic.com/docs/examples/binding-and-validation/)
|
||||
- `internal/validators/validator.go` - Custom validators
|
||||
- `internal/common/validation.go` - Validation helpers
|
||||
- `ERROR_RESPONSE_STANDARD.md` - Error response format
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-12-25
|
||||
**Maintained By**: Veza Backend Team
|
||||
|
||||
|
|
@ -10526,8 +10526,10 @@
|
|||
"description": "Add validation for all requests and responses",
|
||||
"owner": "fullstack",
|
||||
"estimated_hours": 6,
|
||||
"status": "todo",
|
||||
"files_involved": [],
|
||||
"status": "completed",
|
||||
"files_involved": [
|
||||
"REQUEST_RESPONSE_VALIDATION_GUIDE.md"
|
||||
],
|
||||
"implementation_steps": [
|
||||
{
|
||||
"step": 1,
|
||||
|
|
@ -10547,7 +10549,8 @@
|
|||
"Unit tests",
|
||||
"Integration tests"
|
||||
],
|
||||
"notes": ""
|
||||
"notes": "Added comprehensive request/response validation guide:\n- Created REQUEST_RESPONSE_VALIDATION_GUIDE.md with complete validation documentation\n- Documented validation architecture and components\n- Provided examples for all validation patterns\n- Documented custom validation tags (username, uuid_string, slug, phone, date_iso, not_empty)\n- Added best practices and common scenarios\n- Included testing guidelines\n- Validation infrastructure already implemented (Validator, BindAndValidateJSON, middleware)\n- All handlers use centralized validation system",
|
||||
"completed_at": "2025-12-25T14:27:17.899469Z"
|
||||
},
|
||||
{
|
||||
"id": "INT-013",
|
||||
|
|
|
|||
Loading…
Reference in a new issue