This commit is contained in:
Tomas Dvorak
2025-10-19 18:09:28 +02:00
parent abe127fb51
commit 9ccca365b3
40 changed files with 6885 additions and 20 deletions
+783
View File
@@ -0,0 +1,783 @@
# Utility Controllers Guide
This guide explains the new utility controllers that make development easier, simpler, and better.
## Table of Contents
1. [Response Helper](#response-helper)
2. [Pagination Helper](#pagination-helper)
3. [Query Helper](#query-helper)
4. [Validation Helper](#validation-helper)
5. [Audit Log Controller](#audit-log-controller)
6. [Batch Operations Controller](#batch-operations-controller)
7. [Export Helper](#export-helper)
8. [Complete Examples](#complete-examples)
---
## Response Helper
**Purpose:** Standardize all API responses across your application.
### Features
- Consistent response format
- Pre-built status code handlers
- Support for metadata (pagination, etc.)
- Easy error handling
### Usage
```go
import "fotbal-club/internal/controllers"
// Success response
controllers.Respond.Success(c, data, "Operation successful")
// Success with metadata (pagination)
controllers.Respond.SuccessWithMeta(c, articles, paginationMeta, "Articles retrieved")
// Created (201)
controllers.Respond.Created(c, newArticle, "Article created")
// Error responses
controllers.Respond.BadRequest(c, "Invalid input")
controllers.Respond.Unauthorized(c, "Not authenticated")
controllers.Respond.Forbidden(c, "No permission")
controllers.Respond.NotFound(c, "Article not found")
controllers.Respond.InternalError(c, "Database error")
controllers.Respond.ValidationError(c, validationErrors)
// No content (204)
controllers.Respond.NoContent(c)
```
### Response Format
```json
{
"success": true,
"message": "Articles retrieved successfully",
"data": [...],
"meta": {
"page": 1,
"page_size": 20,
"total": 100,
"total_pages": 5,
"has_next": true,
"has_prev": false
}
}
```
---
## Pagination Helper
**Purpose:** Add pagination to any list endpoint with one line of code.
### Features
- Auto-extracts page/page_size from query params
- Calculates pagination metadata
- Works with GORM queries
- Supports preloading
### Usage
```go
// Simple pagination
query := db.Model(&models.Article{}).Where("published = ?", true)
var articles []models.Article
meta, err := controllers.Paginator.Paginate(c, query, &articles)
if err != nil {
controllers.Respond.InternalError(c, "Failed to retrieve articles")
return
}
controllers.Respond.SuccessWithMeta(c, articles, meta, "Success")
// Pagination with preloading
meta, err := controllers.Paginator.PaginateWithPreload(
c, query, &articles, "Author", "Category"
)
```
### Query Parameters
```
GET /api/v1/articles?page=1&page_size=20
```
- `page`: Page number (default: 1)
- `page_size`: Items per page (default: 20, max: 100)
---
## Query Helper
**Purpose:** Simplify filtering, sorting, and searching in list endpoints.
### Features
- Search across multiple fields
- Sort by any field
- Boolean filters
- ID filters (comma-separated)
- Date range filters
- Fluent chain builder
### Usage
#### Basic Sorting
```go
// GET /api/v1/articles?sort=created_at:desc
query := controllers.QueryParser.ApplySortFromContext(
c, db.Model(&models.Article{}), "created_at", "desc"
)
```
#### Basic Search
```go
// GET /api/v1/articles?search=football
query := controllers.QueryParser.ApplySearchFromContext(
c, db.Model(&models.Article{}), "title", "content"
)
```
#### Fluent Chain Builder (Recommended)
```go
// GET /api/v1/articles?search=football&sort=created_at:desc&published=true&category_ids=1,2,3&from=2024-01-01
query := controllers.QueryParser.BuildQueryChain(c, db.Model(&models.Article{})).
WithSearch("title", "content").
WithSort("created_at", "desc").
WithBoolFilter("published", "published").
WithBoolFilter("featured", "featured").
WithIDsFilter("category_ids", "category_id").
WithDateRange("created_at").
Build()
var articles []models.Article
meta, err := controllers.Paginator.Paginate(c, query, &articles)
```
### Supported Query Parameters
- `search` or `q`: Search term
- `sort`: field:order (e.g., `created_at:desc`)
- `published`: Boolean filter (true/false)
- `featured`: Boolean filter (true/false)
- `category_ids`: Comma-separated IDs (e.g., `1,2,3`)
- `from`: Start date (YYYY-MM-DD)
- `to`: End date (YYYY-MM-DD)
---
## Validation Helper
**Purpose:** Validate request data and return user-friendly error messages.
### Features
- Struct validation using tags
- Custom validators (slug, color)
- Automatic error responses
- Input sanitization
### Usage
#### Validate Struct
```go
type CreateArticleRequest struct {
Title string `validate:"required,min=3,max=200"`
Content string `validate:"required,min=10"`
Slug string `validate:"omitempty,slug"`
Email string `validate:"required,email"`
}
var req CreateArticleRequest
if err := c.ShouldBindJSON(&req); err != nil {
controllers.Respond.BadRequest(c, "Invalid JSON")
return
}
// Validate and auto-respond if invalid
if !controllers.Validator.ValidateAndRespond(c, req) {
return // Response already sent
}
// Continue with valid data...
```
#### Sanitization
```go
// Sanitize string (trim, normalize spaces)
title := controllers.Validator.SanitizeString(req.Title)
// Sanitize email (lowercase, trim)
email := controllers.Validator.SanitizeEmail(req.Email)
// Sanitize slug
slug := controllers.Validator.SanitizeSlug(req.Slug)
```
#### Individual Validation
```go
if !controllers.Validator.IsValidEmail(email) {
controllers.Respond.BadRequest(c, "Invalid email")
return
}
```
### Validation Tags
- `required`: Field is required
- `email`: Valid email address
- `url`: Valid URL
- `min=n`: Minimum length
- `max=n`: Maximum length
- `slug`: Valid slug (lowercase, alphanumeric, hyphens)
- `color`: Valid hex color (#RGB, #RRGGBB)
- `oneof=val1 val2`: One of the specified values
- `gte=n`, `lte=n`: Greater/less than or equal
---
## Audit Log Controller
**Purpose:** Track all important actions in your application for compliance and debugging.
### Features
- Automatic user tracking
- IP address and user agent logging
- Before/after change tracking
- Search and filter logs
- Statistics dashboard
### Setup
Add to `main.go`:
```go
// Initialize audit logger
controllers.InitAuditLogger(dbInstance)
// Add to AutoMigrate
&models.AuditLog{},
```
### Usage
#### Log Actions
```go
// Log creation
controllers.AuditLogger.LogCreate(c, "Article", article.ID, "Article created: "+article.Title)
// Log update with changes
before := map[string]interface{}{"title": oldTitle, "published": oldPublished}
after := map[string]interface{}{"title": newTitle, "published": newPublished}
controllers.AuditLogger.LogUpdate(c, "Article", article.ID, "Article updated", before, after)
// Log deletion
controllers.AuditLogger.LogDelete(c, "Article", articleID, "Article deleted: "+title)
// Log login
controllers.AuditLogger.LogLogin(c, userID, true) // success=true
// Log custom action
controllers.AuditLogger.LogEntry(c, "EXPORT", "Article", nil, "Exported articles to CSV", nil)
```
#### API Endpoints (Admin Only)
```go
// Add to routes/routes.go admin group:
admin.GET("/audit-logs", auditLogController.GetAuditLogs)
admin.GET("/audit-logs/:id", auditLogController.GetAuditLogByID)
admin.GET("/audit-logs/entity/:entity_type/:entity_id", auditLogController.GetEntityAuditHistory)
admin.GET("/audit-logs/user/:user_id", auditLogController.GetUserActivityLog)
admin.GET("/audit-logs/stats", auditLogController.GetAuditStats)
admin.POST("/audit-logs/cleanup", auditLogController.CleanupOldLogs)
```
#### Query Audit Logs
```
GET /api/v1/admin/audit-logs?action=CREATE&entity_type=Article&user_id=1&from=2024-01-01&page=1
```
---
## Batch Operations Controller
**Purpose:** Perform bulk operations efficiently.
### Features
- Batch delete
- Batch update
- Batch publish/unpublish
- Batch reorder
- Detailed success/failure reporting
### Setup
```go
// Initialize in main.go or controller
controllers.InitBatchOperations(dbInstance)
```
### Usage
#### Batch Delete
```go
// POST /api/v1/articles/batch-delete
// Body: {"ids": [1, 2, 3, 4, 5]}
func (ac *ArticleController) BatchDeleteArticles(c *gin.Context) {
controllers.BatchOps.BatchDelete(c, &models.Article{}, "articles")
}
```
#### Batch Update
```go
// POST /api/v1/articles/batch-update
// Body: {"ids": [1, 2, 3], "fields": {"published": true, "featured": false}}
func (ac *ArticleController) BatchUpdateArticles(c *gin.Context) {
allowedFields := []string{"published", "featured", "category_id"}
controllers.BatchOps.BatchUpdate(c, &models.Article{}, "articles", allowedFields)
}
```
#### Batch Publish/Unpublish
```go
// POST /api/v1/articles/batch-publish
// Body: {"ids": [1, 2, 3]}
func (ac *ArticleController) BatchPublishArticles(c *gin.Context) {
controllers.BatchOps.BatchPublish(c, &models.Article{}, "articles", true)
}
```
#### Batch Reorder
```go
// POST /api/v1/navigation/batch-reorder
// Body: {"orders": [{"id": 1, "order": 3}, {"id": 2, "order": 1}]}
func (nc *NavigationController) BatchReorderItems(c *gin.Context) {
controllers.BatchOps.BatchReorder(c, &models.NavigationItem{}, "navigation_items")
}
```
### Response Format
```json
{
"success": true,
"data": {
"success": true,
"total_items": 5,
"success_count": 5,
"failure_count": 0,
"errors": []
},
"message": "Successfully deleted 5 items"
}
```
---
## Export Helper
**Purpose:** Export data to CSV or JSON format.
### Usage
#### Export to CSV
```go
func (ac *ArticleController) ExportArticlesToCSV(c *gin.Context) {
var articles []models.Article
if err := db.Find(&articles).Error; err != nil {
controllers.Respond.InternalError(c, "Failed to retrieve articles")
return
}
headers := []string{"ID", "Title", "Published", "Created At"}
filename := fmt.Sprintf("articles_%s.csv", time.Now().Format("20060102"))
if err := controllers.Exporter.ExportToCSV(c, articles, filename, headers); err != nil {
controllers.Respond.InternalError(c, "Export failed")
return
}
}
```
#### Export to JSON
```go
func (ac *ArticleController) ExportArticlesToJSON(c *gin.Context) {
var articles []models.Article
if err := db.Find(&articles).Error; err != nil {
controllers.Respond.InternalError(c, "Failed to retrieve articles")
return
}
filename := fmt.Sprintf("articles_%s.json", time.Now().Format("20060102"))
if err := controllers.Exporter.ExportToJSON(c, articles, filename); err != nil {
controllers.Respond.InternalError(c, "Export failed")
return
}
}
```
---
## Complete Examples
### Example 1: Simple CRUD Controller
```go
package controllers
import (
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type SimpleArticleController struct {
DB *gorm.DB
}
// List articles with search, filter, sort, and pagination
func (sac *SimpleArticleController) List(c *gin.Context) {
query := QueryParser.BuildQueryChain(c, sac.DB.Model(&models.Article{})).
WithSearch("title", "content").
WithSort("created_at", "desc").
WithBoolFilter("published", "published").
Build()
var articles []models.Article
meta, err := Paginator.PaginateWithPreload(c, query, &articles, "Author", "Category")
if err != nil {
Respond.InternalError(c, "Failed to retrieve articles")
return
}
Respond.SuccessWithMeta(c, articles, meta, "Articles retrieved successfully")
}
// Get single article
func (sac *SimpleArticleController) Get(c *gin.Context) {
id := c.Param("id")
var article models.Article
if err := sac.DB.Preload("Author").Preload("Category").First(&article, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
Respond.NotFound(c, "Article not found")
return
}
Respond.InternalError(c, "Failed to retrieve article")
return
}
Respond.Success(c, article, "Article retrieved successfully")
}
// Create article
func (sac *SimpleArticleController) Create(c *gin.Context) {
var req struct {
Title string `json:"title" validate:"required,min=3,max=200"`
Content string `json:"content" validate:"required,min=10"`
Slug string `json:"slug" validate:"omitempty,slug"`
}
if err := c.ShouldBindJSON(&req); err != nil {
Respond.BadRequest(c, "Invalid JSON")
return
}
if !Validator.ValidateAndRespond(c, req) {
return
}
article := models.Article{
Title: Validator.SanitizeString(req.Title),
Content: req.Content,
Slug: Validator.SanitizeSlug(req.Slug),
}
if err := sac.DB.Create(&article).Error; err != nil {
Respond.InternalError(c, "Failed to create article")
return
}
AuditLogger.LogCreate(c, "Article", article.ID, "Article created: "+article.Title)
Respond.Created(c, article, "Article created successfully")
}
// Update article
func (sac *SimpleArticleController) Update(c *gin.Context) {
id := c.Param("id")
var article models.Article
if err := sac.DB.First(&article, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
Respond.NotFound(c, "Article not found")
return
}
Respond.InternalError(c, "Failed to retrieve article")
return
}
oldTitle := article.Title
var req struct {
Title string `json:"title" validate:"omitempty,min=3,max=200"`
}
if err := c.ShouldBindJSON(&req); err != nil {
Respond.BadRequest(c, "Invalid JSON")
return
}
if !Validator.ValidateAndRespond(c, req) {
return
}
if req.Title != "" {
article.Title = Validator.SanitizeString(req.Title)
}
if err := sac.DB.Save(&article).Error; err != nil {
Respond.InternalError(c, "Failed to update article")
return
}
AuditLogger.LogUpdate(c, "Article", article.ID, "Article updated",
map[string]interface{}{"title": oldTitle},
map[string]interface{}{"title": article.Title})
Respond.Success(c, article, "Article updated successfully")
}
// Delete article
func (sac *SimpleArticleController) Delete(c *gin.Context) {
id := c.Param("id")
var article models.Article
if err := sac.DB.First(&article, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
Respond.NotFound(c, "Article not found")
return
}
Respond.InternalError(c, "Failed to retrieve article")
return
}
title := article.Title
articleID := article.ID
if err := sac.DB.Delete(&article).Error; err != nil {
Respond.InternalError(c, "Failed to delete article")
return
}
AuditLogger.LogDelete(c, "Article", articleID, "Article deleted: "+title)
Respond.NoContent(c)
}
```
### Example 2: Route Registration
```go
// In routes/routes.go
func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
// Initialize helpers
controllers.InitAuditLogger(db)
controllers.InitBatchOperations(db)
// Article routes with new utilities
articles := api.Group("/articles")
{
articleCtrl := &controllers.SimpleArticleController{DB: db}
// Public routes
articles.GET("", articleCtrl.List)
articles.GET("/:id", articleCtrl.Get)
// Protected routes
protected := articles.Group("")
protected.Use(middleware.JWTAuth(db))
{
protected.POST("", articleCtrl.Create)
protected.PUT("/:id", articleCtrl.Update)
protected.DELETE("/:id", articleCtrl.Delete)
// Batch operations
protected.POST("/batch-delete", articleCtrl.BatchDelete)
protected.POST("/batch-publish", articleCtrl.BatchPublish)
// Export
protected.GET("/export/csv", articleCtrl.ExportCSV)
protected.GET("/export/json", articleCtrl.ExportJSON)
}
}
// Audit logs (admin only)
auditLogCtrl := controllers.NewAuditLogController(db)
admin := api.Group("/admin")
admin.Use(middleware.JWTAuth(db), middleware.RoleAuth("admin"))
{
admin.GET("/audit-logs", auditLogCtrl.GetAuditLogs)
admin.GET("/audit-logs/:id", auditLogCtrl.GetAuditLogByID)
admin.GET("/audit-logs/stats", auditLogCtrl.GetAuditStats)
admin.POST("/audit-logs/cleanup", auditLogCtrl.CleanupOldLogs)
}
}
```
---
## Benefits
### Before (Old Way)
```go
func GetArticles(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
offset := (page - 1) * pageSize
var articles []models.Article
var total int64
query := db.Model(&models.Article{})
query.Count(&total)
query.Offset(offset).Limit(pageSize).Find(&articles)
totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
c.JSON(200, gin.H{
"data": articles,
"page": page,
"page_size": pageSize,
"total": total,
"total_pages": totalPages,
})
}
```
### After (New Way)
```go
func GetArticles(c *gin.Context) {
query := QueryParser.BuildQueryChain(c, db.Model(&models.Article{})).
WithSearch("title", "content").
WithSort("created_at", "desc").
Build()
var articles []models.Article
meta, _ := Paginator.Paginate(c, query, &articles)
Respond.SuccessWithMeta(c, articles, meta, "Success")
}
```
### Comparison
- **Lines of code:** 24 → 7 (70% reduction)
- **Consistency:** Standardized across all endpoints
- **Features:** Added search, sorting, filtering
- **Error handling:** Built-in
- **Maintainability:** Single source of truth
---
## Migration Guide
### Step 1: Add Model Migration
```go
// In main.go AutoMigrate
&models.AuditLog{},
```
### Step 2: Initialize in main.go
```go
// After database initialization
controllers.InitAuditLogger(dbInstance)
controllers.InitBatchOperations(dbInstance)
```
### Step 3: Update Existing Controllers
Replace manual pagination, filtering, and response code with the new utilities.
### Step 4: Add go-playground/validator Dependency
```bash
go get github.com/go-playground/validator/v10
```
---
## Best Practices
1. **Always use standardized responses** - Use `Respond.*` methods
2. **Log important actions** - Use `AuditLogger` for CREATE, UPDATE, DELETE
3. **Validate inputs** - Use `Validator.ValidateAndRespond`
4. **Sanitize user input** - Use `Validator.Sanitize*` methods
5. **Use query chains** - Use `QueryParser.BuildQueryChain` for complex queries
6. **Paginate large lists** - Always use `Paginator` for list endpoints
7. **Batch operations** - Use `BatchOps` for bulk actions
8. **Export functionality** - Use `Exporter` for CSV/JSON exports
---
## Testing
```bash
# Test pagination
curl "http://localhost:8080/api/v1/articles?page=1&page_size=10"
# Test search
curl "http://localhost:8080/api/v1/articles?search=football"
# Test sorting
curl "http://localhost:8080/api/v1/articles?sort=created_at:desc"
# Test combined
curl "http://localhost:8080/api/v1/articles?search=football&sort=created_at:desc&published=true&page=1"
# Test batch delete
curl -X POST "http://localhost:8080/api/v1/articles/batch-delete" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ids": [1, 2, 3]}'
# Test audit logs
curl "http://localhost:8080/api/v1/admin/audit-logs?action=CREATE&entity_type=Article"
```
---
## Conclusion
These utility controllers dramatically reduce boilerplate code, improve consistency, and make your codebase more maintainable. They follow Go best practices and provide a solid foundation for rapid development.
**Key Improvements:**
- 70% less code for common operations
- Consistent API responses
- Built-in validation and sanitization
- Comprehensive audit logging
- Efficient batch operations
- Easy data export
- Better error handling
- Improved developer experience