529 lines
13 KiB
Markdown
529 lines
13 KiB
Markdown
|
|
# Handler Pattern in Go
|
||
|
|
|
||
|
|
## Pattern Overview
|
||
|
|
|
||
|
|
The Handler Pattern organizes HTTP endpoint logic into structured, testable components. This project uses a method-based handler approach where related endpoints are grouped as methods on a handler struct.
|
||
|
|
|
||
|
|
## Pattern Structure
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Handler struct holds dependencies
|
||
|
|
type Handler struct {
|
||
|
|
tmpl *templates.Manager
|
||
|
|
db *database.DB
|
||
|
|
// other dependencies
|
||
|
|
}
|
||
|
|
|
||
|
|
// Constructor with dependency injection
|
||
|
|
func NewHandler(tmpl *templates.Manager, db *database.DB) *Handler {
|
||
|
|
return &Handler{
|
||
|
|
tmpl: tmpl,
|
||
|
|
db: db,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// HTTP handler methods
|
||
|
|
func (h *Handler) MethodName(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// Handle request
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Real Implementation from Project
|
||
|
|
|
||
|
|
### CVHandler Structure
|
||
|
|
|
||
|
|
```go
|
||
|
|
// internal/handlers/cv.go
|
||
|
|
|
||
|
|
// CVHandler handles CV-related HTTP requests
|
||
|
|
type CVHandler struct {
|
||
|
|
tmpl *templates.Manager // Template renderer
|
||
|
|
host string // Server host for absolute URLs
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewCVHandler creates a new CV handler with dependencies
|
||
|
|
func NewCVHandler(tmpl *templates.Manager, host string) *CVHandler {
|
||
|
|
return &CVHandler{
|
||
|
|
tmpl: tmpl,
|
||
|
|
host: host,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Page Handlers
|
||
|
|
|
||
|
|
```go
|
||
|
|
// internal/handlers/cv_pages.go
|
||
|
|
|
||
|
|
// Home renders the main CV page
|
||
|
|
func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// Get user preferences from context (set by middleware)
|
||
|
|
prefs := middleware.GetPreferences(r)
|
||
|
|
|
||
|
|
// Get language from query params, fallback to preference
|
||
|
|
lang := r.URL.Query().Get("lang")
|
||
|
|
if lang == "" {
|
||
|
|
lang = prefs.CVLanguage
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate language
|
||
|
|
if err := validateLanguage(lang); err != nil {
|
||
|
|
h.HandleError(w, r, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Prepare template data
|
||
|
|
data, err := h.prepareTemplateData(lang)
|
||
|
|
if err != nil {
|
||
|
|
h.HandleError(w, r, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Render template
|
||
|
|
if err := h.tmpl.Render(w, "index.html", data); err != nil {
|
||
|
|
h.HandleError(w, r, TemplateError(err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// CVContent renders just the CV content (for HTMX partial updates)
|
||
|
|
func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request) {
|
||
|
|
prefs := middleware.GetPreferences(r)
|
||
|
|
lang := prefs.CVLanguage
|
||
|
|
|
||
|
|
data, err := h.prepareTemplateData(lang)
|
||
|
|
if err != nil {
|
||
|
|
h.HandleError(w, r, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := h.tmpl.Render(w, "partials/cv_content.html", data); err != nil {
|
||
|
|
h.HandleError(w, r, TemplateError(err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### HTMX Toggle Handlers
|
||
|
|
|
||
|
|
```go
|
||
|
|
// internal/handlers/cv_htmx.go
|
||
|
|
|
||
|
|
// ToggleCVLength toggles between short and long CV formats
|
||
|
|
func (h *CVHandler) ToggleCVLength(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// Get current preferences from context
|
||
|
|
prefs := middleware.GetPreferences(r)
|
||
|
|
currentLength := prefs.CVLength
|
||
|
|
|
||
|
|
// Toggle state
|
||
|
|
newLength := "long"
|
||
|
|
if currentLength == "long" {
|
||
|
|
newLength = "short"
|
||
|
|
}
|
||
|
|
|
||
|
|
// Save new preference
|
||
|
|
middleware.SetPreferenceCookie(w, "cv-length", newLength)
|
||
|
|
|
||
|
|
// Render updated content
|
||
|
|
lang := middleware.GetLanguage(r)
|
||
|
|
data, err := h.prepareTemplateData(lang)
|
||
|
|
if err != nil {
|
||
|
|
h.HandleError(w, r, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := h.tmpl.Render(w, "partials/cv_content.html", data); err != nil {
|
||
|
|
h.HandleError(w, r, TemplateError(err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ToggleCVIcons toggles icon visibility
|
||
|
|
func (h *CVHandler) ToggleCVIcons(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// Similar pattern: get → toggle → save → render
|
||
|
|
// ...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Helper Methods
|
||
|
|
|
||
|
|
```go
|
||
|
|
// internal/handlers/cv_helpers.go
|
||
|
|
|
||
|
|
// prepareTemplateData loads and prepares all data for template rendering
|
||
|
|
func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, error) {
|
||
|
|
// Load CV data
|
||
|
|
cv, err := cvmodel.LoadCV(lang)
|
||
|
|
if err != nil {
|
||
|
|
return nil, DataNotFoundError("CV", lang).WithErr(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Load UI strings
|
||
|
|
ui, err := uimodel.LoadUI(lang)
|
||
|
|
if err != nil {
|
||
|
|
return nil, DataNotFoundError("UI", lang).WithErr(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Calculate experience durations
|
||
|
|
for i := range cv.Experience {
|
||
|
|
cv.Experience[i].Duration = calculateDuration(
|
||
|
|
cv.Experience[i].StartDate,
|
||
|
|
cv.Experience[i].EndDate,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Split skills into columns
|
||
|
|
skillColumns := splitSkillsIntoColumns(cv.Skills.Technical, 3)
|
||
|
|
|
||
|
|
// Build data map
|
||
|
|
return map[string]interface{}{
|
||
|
|
"CV": cv,
|
||
|
|
"UI": ui,
|
||
|
|
"SkillsColumns": skillColumns,
|
||
|
|
"PageTitle": fmt.Sprintf("%s - %s", cv.Personal.Name, cv.Personal.Title),
|
||
|
|
"CanonicalURL": h.getFullURL("/"),
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// getFullURL builds absolute URLs for SEO
|
||
|
|
func (h *CVHandler) getFullURL(path string) string {
|
||
|
|
return fmt.Sprintf("http://%s%s", h.host, path)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Handler Organization by File
|
||
|
|
|
||
|
|
### Separation of Concerns
|
||
|
|
|
||
|
|
```
|
||
|
|
internal/handlers/
|
||
|
|
├── cv.go Constructor, shared state
|
||
|
|
├── cv_pages.go Full page renders (Home, CVContent)
|
||
|
|
├── cv_htmx.go HTMX partial updates (4 toggles)
|
||
|
|
├── cv_pdf.go PDF export endpoint
|
||
|
|
├── cv_helpers.go Shared utilities
|
||
|
|
├── types.go Request/response types
|
||
|
|
└── errors.go Error handling
|
||
|
|
```
|
||
|
|
|
||
|
|
This separation provides:
|
||
|
|
1. **Clear boundaries**: Each file has a specific purpose
|
||
|
|
2. **Easier navigation**: Find code by responsibility
|
||
|
|
3. **Better testing**: Test files mirror source files
|
||
|
|
4. **Reduced conflicts**: Multiple developers can work in parallel
|
||
|
|
|
||
|
|
## Route Registration
|
||
|
|
|
||
|
|
```go
|
||
|
|
// internal/routes/routes.go
|
||
|
|
|
||
|
|
func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) http.Handler {
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
|
||
|
|
// Page routes
|
||
|
|
mux.HandleFunc("/", cvHandler.Home)
|
||
|
|
mux.HandleFunc("/cv", cvHandler.CVContent)
|
||
|
|
|
||
|
|
// HTMX toggle routes
|
||
|
|
mux.HandleFunc("/toggle/length", cvHandler.ToggleCVLength)
|
||
|
|
mux.HandleFunc("/toggle/icons", cvHandler.ToggleCVIcons)
|
||
|
|
mux.HandleFunc("/toggle/theme", cvHandler.ToggleCVTheme)
|
||
|
|
mux.HandleFunc("/toggle/language", cvHandler.ToggleLanguage)
|
||
|
|
|
||
|
|
// PDF export route (with additional middleware)
|
||
|
|
pdfHandler := middleware.OriginChecker(
|
||
|
|
middleware.RateLimiter(
|
||
|
|
http.HandlerFunc(cvHandler.ExportPDF),
|
||
|
|
3, // 3 requests per minute
|
||
|
|
),
|
||
|
|
)
|
||
|
|
mux.Handle("/export/pdf", pdfHandler)
|
||
|
|
|
||
|
|
// Health check
|
||
|
|
mux.HandleFunc("/health", healthHandler.Health)
|
||
|
|
|
||
|
|
// Static files
|
||
|
|
fs := http.FileServer(http.Dir("static"))
|
||
|
|
mux.Handle("/static/", http.StripPrefix("/static/", fs))
|
||
|
|
|
||
|
|
// Apply global middleware
|
||
|
|
handler := middleware.Recovery(
|
||
|
|
middleware.Logger(
|
||
|
|
middleware.SecurityHeaders(
|
||
|
|
middleware.PreferencesMiddleware(mux),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
return handler
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Handler Benefits
|
||
|
|
|
||
|
|
### 1. Dependency Injection
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Dependencies are explicit and injectable
|
||
|
|
type CVHandler struct {
|
||
|
|
tmpl *templates.Manager // Can be mocked
|
||
|
|
db *database.DB // Can be mocked
|
||
|
|
cache *redis.Client // Can be mocked
|
||
|
|
}
|
||
|
|
|
||
|
|
// Easy to test with mocks
|
||
|
|
func TestHome(t *testing.T) {
|
||
|
|
mockTmpl := &MockTemplateManager{}
|
||
|
|
handler := NewCVHandler(mockTmpl, "localhost:8080")
|
||
|
|
// Test with mock
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Shared Logic
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Helpers available to all handler methods
|
||
|
|
func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, error) {
|
||
|
|
// Reused by Home(), CVContent(), ToggleCVLength(), etc.
|
||
|
|
}
|
||
|
|
|
||
|
|
func (h *CVHandler) HandleError(w http.ResponseWriter, r *http.Request, err error) {
|
||
|
|
// Centralized error handling for all methods
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. Context Access
|
||
|
|
|
||
|
|
```go
|
||
|
|
// All handler methods have access to:
|
||
|
|
// - Dependencies (h.tmpl, h.host)
|
||
|
|
// - Request (r)
|
||
|
|
// - Response (w)
|
||
|
|
func (h *CVHandler) AnyMethod(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// Can access h.tmpl, h.host, etc.
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Alternative Handler Patterns
|
||
|
|
|
||
|
|
### 1. Function-Based Handlers
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Simple approach for small apps
|
||
|
|
func Home(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// No struct, just a function
|
||
|
|
// Dependencies passed as globals or closures
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**When to use**: Very small apps, simple endpoints
|
||
|
|
**Drawbacks**: Hard to test, shared logic difficult, no dependency injection
|
||
|
|
|
||
|
|
### 2. Handler with Interface
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Interface-based approach
|
||
|
|
type Handler interface {
|
||
|
|
Home(w http.ResponseWriter, r *http.Request)
|
||
|
|
Profile(w http.ResponseWriter, r *http.Request)
|
||
|
|
}
|
||
|
|
|
||
|
|
type CVHandler struct {
|
||
|
|
// ...
|
||
|
|
}
|
||
|
|
|
||
|
|
func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// ...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**When to use**: Multiple implementations, complex testing
|
||
|
|
**Drawbacks**: More boilerplate, potentially over-engineered
|
||
|
|
|
||
|
|
### 3. Handler with http.Handler Interface
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Implement http.Handler interface directly
|
||
|
|
type HomeHandler struct {
|
||
|
|
tmpl *templates.Manager
|
||
|
|
}
|
||
|
|
|
||
|
|
func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// Handle request
|
||
|
|
}
|
||
|
|
|
||
|
|
// Register
|
||
|
|
mux.Handle("/", &HomeHandler{tmpl: tmplManager})
|
||
|
|
```
|
||
|
|
|
||
|
|
**When to use**: When you need to pass handlers around as interfaces
|
||
|
|
**Drawbacks**: One handler per endpoint, lots of small types
|
||
|
|
|
||
|
|
## Testing Handlers
|
||
|
|
|
||
|
|
### Unit Test Example
|
||
|
|
|
||
|
|
```go
|
||
|
|
// internal/handlers/cv_pages_test.go
|
||
|
|
|
||
|
|
func TestHome(t *testing.T) {
|
||
|
|
// Setup
|
||
|
|
cfg := &config.TemplateConfig{
|
||
|
|
Dir: "../../templates",
|
||
|
|
PartialsDir: "../../templates/partials",
|
||
|
|
HotReload: true,
|
||
|
|
}
|
||
|
|
tmplManager, err := templates.NewManager(cfg)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
handler := NewCVHandler(tmplManager, "localhost:8080")
|
||
|
|
|
||
|
|
// Create test request
|
||
|
|
req := httptest.NewRequest(http.MethodGet, "/?lang=en", nil)
|
||
|
|
w := httptest.NewRecorder()
|
||
|
|
|
||
|
|
// Execute
|
||
|
|
handler.Home(w, req)
|
||
|
|
|
||
|
|
// Verify
|
||
|
|
if w.Code != http.StatusOK {
|
||
|
|
t.Errorf("expected 200, got %d", w.Code)
|
||
|
|
}
|
||
|
|
|
||
|
|
body := w.Body.String()
|
||
|
|
if !strings.Contains(body, "<!DOCTYPE html>") {
|
||
|
|
t.Error("response should be HTML")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Table-Driven Tests
|
||
|
|
|
||
|
|
```go
|
||
|
|
func TestHome(t *testing.T) {
|
||
|
|
tests := []struct {
|
||
|
|
name string
|
||
|
|
lang string
|
||
|
|
wantStatus int
|
||
|
|
wantBody string
|
||
|
|
}{
|
||
|
|
{
|
||
|
|
name: "English version",
|
||
|
|
lang: "en",
|
||
|
|
wantStatus: http.StatusOK,
|
||
|
|
wantBody: "Professional Summary",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "Spanish version",
|
||
|
|
lang: "es",
|
||
|
|
wantStatus: http.StatusOK,
|
||
|
|
wantBody: "Resumen Profesional",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: "Invalid language",
|
||
|
|
lang: "xx",
|
||
|
|
wantStatus: http.StatusBadRequest,
|
||
|
|
wantBody: "INVALID_LANGUAGE",
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, tt := range tests {
|
||
|
|
t.Run(tt.name, func(t *testing.T) {
|
||
|
|
req := httptest.NewRequest(http.MethodGet, "/?lang="+tt.lang, nil)
|
||
|
|
w := httptest.NewRecorder()
|
||
|
|
|
||
|
|
handler.Home(w, req)
|
||
|
|
|
||
|
|
if w.Code != tt.wantStatus {
|
||
|
|
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
|
||
|
|
}
|
||
|
|
|
||
|
|
if !strings.Contains(w.Body.String(), tt.wantBody) {
|
||
|
|
t.Errorf("body missing %q", tt.wantBody)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Best Practices
|
||
|
|
|
||
|
|
### ✅ DO
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Keep handlers focused on HTTP concerns
|
||
|
|
func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// Parse request
|
||
|
|
// Validate input
|
||
|
|
// Call business logic
|
||
|
|
// Render response
|
||
|
|
}
|
||
|
|
|
||
|
|
// Extract business logic to helpers
|
||
|
|
func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, error) {
|
||
|
|
// This can be tested independently
|
||
|
|
}
|
||
|
|
|
||
|
|
// Use dependency injection
|
||
|
|
func NewCVHandler(tmpl *templates.Manager, host string) *CVHandler {
|
||
|
|
return &CVHandler{tmpl: tmpl, host: host}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Group related handlers
|
||
|
|
type CVHandler struct {
|
||
|
|
// CV-related endpoints
|
||
|
|
}
|
||
|
|
type UserHandler struct {
|
||
|
|
// User-related endpoints
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### ❌ DON'T
|
||
|
|
|
||
|
|
```go
|
||
|
|
// DON'T put business logic in handlers
|
||
|
|
func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// 500 lines of business logic here...
|
||
|
|
}
|
||
|
|
|
||
|
|
// DON'T use global state
|
||
|
|
var globalTemplateManager *templates.Manager
|
||
|
|
|
||
|
|
// DON'T mix unrelated endpoints
|
||
|
|
type Handler struct {
|
||
|
|
// CV, Users, Orders, Payments all in one struct
|
||
|
|
}
|
||
|
|
|
||
|
|
// DON'T ignore errors
|
||
|
|
func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
|
||
|
|
data, _ := h.prepareTemplateData(lang) // Ignoring error!
|
||
|
|
h.tmpl.Render(w, "index.html", data)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Handler Testing Checklist
|
||
|
|
|
||
|
|
- [ ] Test happy path
|
||
|
|
- [ ] Test invalid input
|
||
|
|
- [ ] Test missing data
|
||
|
|
- [ ] Test error handling
|
||
|
|
- [ ] Test with different preferences/context
|
||
|
|
- [ ] Test response headers
|
||
|
|
- [ ] Test response status codes
|
||
|
|
- [ ] Test response body content
|
||
|
|
|
||
|
|
## Related Patterns
|
||
|
|
|
||
|
|
- **Dependency Injection**: Used in handler constructors
|
||
|
|
- **Middleware Pattern**: Wraps handlers for cross-cutting concerns
|
||
|
|
- **Context Pattern**: Request-scoped values in handlers
|
||
|
|
- **Error Wrapping**: Structured error handling in handlers
|
||
|
|
|
||
|
|
## Further Reading
|
||
|
|
|
||
|
|
- [HTTP Handler Pattern](https://www.alexedwards.net/blog/a-recap-of-request-handling)
|
||
|
|
- [Structuring Go Applications](https://www.gobeyond.dev/standard-package-layout/)
|
||
|
|
- [Dependency Injection](./05-dependency-injection.md)
|