- Archiver 131 .md dans docs/archive/root-md/ - Archiver 22 .json dans docs/archive/root-json/ - Conserver 7 .md utiles (README, CONTRIBUTING, CHANGELOG, etc.) - Conserver package.json, package-lock.json, turbo.json - Ajouter README d'index dans chaque archive
549 lines
14 KiB
Markdown
549 lines
14 KiB
Markdown
# 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
|
|
|