feat: simplify architecture by removing cache layer and centralizing routes

- Removed over-engineered cache system for static CV data that only changes on deployment
- Extracted all route configuration to internal/routes/routes.go for better organization
- Implemented rate limiting and cache control middleware for PDF endpoint protection
This commit is contained in:
juanatsap
2025-11-12 17:53:24 +00:00
parent 927d257f2c
commit 211fd05462
18 changed files with 967 additions and 3042 deletions
-189
View File
@@ -1,189 +0,0 @@
package cache
import (
"fmt"
"log"
"sync"
"time"
)
// CacheEntry represents a cached item with expiration
type cacheEntry struct {
data interface{}
expiration time.Time
}
// CVCache provides thread-safe caching for CV and UI data
type CVCache struct {
mu sync.RWMutex
entries map[string]*cacheEntry
ttl time.Duration
stats CacheStats
}
// CacheStats tracks cache performance metrics
type CacheStats struct {
mu sync.RWMutex
Hits int64
Misses int64
Size int
}
// New creates a new CVCache with the specified TTL
func New(ttl time.Duration) *CVCache {
if ttl <= 0 {
ttl = 1 * time.Hour // Default TTL
}
cache := &CVCache{
entries: make(map[string]*cacheEntry),
ttl: ttl,
stats: CacheStats{},
}
// Start background cleanup goroutine
go cache.cleanupExpired()
return cache
}
// Get retrieves an item from the cache
func (c *CVCache) Get(key string) (interface{}, bool) {
c.mu.RLock()
entry, exists := c.entries[key]
c.mu.RUnlock()
if !exists {
c.recordMiss()
return nil, false
}
// Check if expired
if time.Now().After(entry.expiration) {
c.mu.Lock()
delete(c.entries, key)
c.mu.Unlock()
c.recordMiss()
return nil, false
}
c.recordHit()
return entry.data, true
}
// Set stores an item in the cache with TTL
func (c *CVCache) Set(key string, data interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[key] = &cacheEntry{
data: data,
expiration: time.Now().Add(c.ttl),
}
c.updateSize()
}
// Invalidate removes a specific key from cache
func (c *CVCache) Invalidate(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, key)
c.updateSize()
}
// InvalidateAll clears the entire cache
func (c *CVCache) InvalidateAll() {
c.mu.Lock()
defer c.mu.Unlock()
c.entries = make(map[string]*cacheEntry)
c.updateSize()
log.Println("🗑️ Cache invalidated")
}
// Warm preloads cache with specific keys
func (c *CVCache) Warm(key string, loader func() (interface{}, error)) error {
data, err := loader()
if err != nil {
return fmt.Errorf("cache warm failed for key %s: %w", key, err)
}
c.Set(key, data)
log.Printf("🔥 Cache warmed: %s", key)
return nil
}
// GetStats returns current cache statistics
func (c *CVCache) GetStats() CacheStats {
c.stats.mu.RLock()
defer c.stats.mu.RUnlock()
return CacheStats{
Hits: c.stats.Hits,
Misses: c.stats.Misses,
Size: c.stats.Size,
}
}
// HitRate returns the cache hit rate as a percentage
func (c *CVCache) HitRate() float64 {
stats := c.GetStats()
total := stats.Hits + stats.Misses
if total == 0 {
return 0
}
return (float64(stats.Hits) / float64(total)) * 100
}
// recordHit increments the hit counter
func (c *CVCache) recordHit() {
c.stats.mu.Lock()
c.stats.Hits++
c.stats.mu.Unlock()
}
// recordMiss increments the miss counter
func (c *CVCache) recordMiss() {
c.stats.mu.Lock()
c.stats.Misses++
c.stats.mu.Unlock()
}
// updateSize updates the cached size count
func (c *CVCache) updateSize() {
c.stats.mu.Lock()
c.stats.Size = len(c.entries)
c.stats.mu.Unlock()
}
// cleanupExpired periodically removes expired entries
func (c *CVCache) cleanupExpired() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
now := time.Now()
removed := 0
for key, entry := range c.entries {
if now.After(entry.expiration) {
delete(c.entries, key)
removed++
}
}
if removed > 0 {
c.updateSize()
log.Printf("🧹 Cache cleanup: removed %d expired entries", removed)
}
c.mu.Unlock()
}
}
// BuildKey creates a consistent cache key
func BuildKey(prefix, lang string) string {
return fmt.Sprintf("%s:%s", prefix, lang)
}
+3 -26
View File
@@ -5,24 +5,13 @@ import (
"log"
"net/http"
"time"
"github.com/juanatsap/cv-site/internal/models"
)
// HealthResponse represents the health check response
type HealthResponse struct {
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version"`
Cache *CacheInfo `json:"cache,omitempty"`
}
// CacheInfo represents cache statistics
type CacheInfo struct {
Hits int64 `json:"hits"`
Misses int64 `json:"misses"`
Size int `json:"size"`
HitRate float64 `json:"hit_rate_percent"`
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version"`
}
// HealthHandler handles health check requests
@@ -45,21 +34,9 @@ func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
Version: h.version,
}
// Include cache stats if available
if cache := models.GetCache(); cache != nil {
stats := cache.GetStats()
response.Cache = &CacheInfo{
Hits: stats.Hits,
Misses: stats.Misses,
Size: stats.Size,
HitRate: cache.HitRate(),
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
// Log error but don't change response status (already written)
log.Printf("ERROR encoding health check response: %v", err)
}
}
+14
View File
@@ -221,3 +221,17 @@ func (rl *RateLimiter) cleanup() {
rl.mu.Unlock()
}
}
// CacheControl adds cache headers to static files
// 1 hour in development, 1 day in production
func CacheControl(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
maxAge := "3600" // 1 hour
if os.Getenv("GO_ENV") == "production" {
maxAge = "86400" // 1 day
}
w.Header().Set("Cache-Control", "public, max-age="+maxAge)
next.ServeHTTP(w, r)
})
}
-81
View File
@@ -4,11 +4,7 @@ import (
"encoding/json"
"fmt"
"html/template"
"log"
"os"
"time"
"github.com/juanatsap/cv-site/internal/cache"
)
// CV represents the complete curriculum vitae structure
@@ -16,7 +12,6 @@ type CV struct {
Personal Personal `json:"personal"`
Summary string `json:"summary"`
Experience []Experience `json:"experience"`
AIDevelopment AIDevelopment `json:"ai_development"`
Education []Education `json:"education"`
Skills Skills `json:"skills"`
Languages []Language `json:"languages"`
@@ -62,20 +57,6 @@ type Experience struct {
Duration string `json:"-"` // Calculated field, not from JSON
}
type AIDevelopment struct {
Title string `json:"title"`
Period string `json:"period"`
Description string `json:"description"`
Skills []AISkill `json:"skills"`
Achievements []string `json:"achievements"`
}
type AISkill struct {
Category string `json:"category"`
Proficiency string `json:"proficiency"`
Items []string `json:"items"`
}
type Education struct {
Degree string `json:"degree"`
Institution string `json:"institution"`
@@ -193,104 +174,42 @@ type TechStack struct {
CSS3 string `json:"css3"`
}
// Global cache instance (initialized in main.go)
var cvCache *cache.CVCache
// InitCache initializes the global cache with specified TTL
func InitCache(ttl time.Duration) {
cvCache = cache.New(ttl)
log.Printf("✓ Cache initialized (TTL: %v)", ttl)
}
// GetCache returns the cache instance (for stats/management)
func GetCache() *cache.CVCache {
return cvCache
}
// LoadCV loads CV data from a JSON file for the specified language
// Uses cache if available, falls back to disk on cache miss
func LoadCV(lang string) (*CV, error) {
// Validate language
if lang != "en" && lang != "es" {
return nil, fmt.Errorf("unsupported language: %s", lang)
}
// Try cache first if available
if cvCache != nil {
cacheKey := cache.BuildKey("cv", lang)
if cached, found := cvCache.Get(cacheKey); found {
if cv, ok := cached.(*CV); ok {
return cv, nil
}
// Invalid cache entry, invalidate it
cvCache.Invalidate(cacheKey)
}
}
// Cache miss or no cache - load from disk
filename := fmt.Sprintf("data/cv-%s.json", lang)
// Read the JSON file
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("error reading file %s: %w", filename, err)
}
// Parse JSON
var cv CV
if err := json.Unmarshal(data, &cv); err != nil {
return nil, fmt.Errorf("error parsing JSON: %w", err)
}
// Store in cache if available
if cvCache != nil {
cacheKey := cache.BuildKey("cv", lang)
cvCache.Set(cacheKey, &cv)
}
return &cv, nil
}
// LoadUI loads UI translations from a JSON file for the specified language
// Uses cache if available, falls back to disk on cache miss
func LoadUI(lang string) (*UI, error) {
// Validate language
if lang != "en" && lang != "es" {
return nil, fmt.Errorf("unsupported language: %s", lang)
}
// Try cache first if available
if cvCache != nil {
cacheKey := cache.BuildKey("ui", lang)
if cached, found := cvCache.Get(cacheKey); found {
if ui, ok := cached.(*UI); ok {
return ui, nil
}
// Invalid cache entry, invalidate it
cvCache.Invalidate(cacheKey)
}
}
// Cache miss or no cache - load from disk
filename := fmt.Sprintf("data/ui-%s.json", lang)
// Read the JSON file
data, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("error reading file %s: %w", filename, err)
}
// Parse JSON
var ui UI
if err := json.Unmarshal(data, &ui); err != nil {
return nil, fmt.Errorf("error parsing JSON: %w", err)
}
// Store in cache if available
if cvCache != nil {
cacheKey := cache.BuildKey("ui", lang)
cvCache.Set(cacheKey, &ui)
}
return &ui, nil
}
+41
View File
@@ -0,0 +1,41 @@
package routes
import (
"net/http"
"time"
"github.com/juanatsap/cv-site/internal/handlers"
"github.com/juanatsap/cv-site/internal/middleware"
)
// Setup configures all application routes and middleware
func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) http.Handler {
mux := http.NewServeMux()
// Public routes
mux.HandleFunc("/", cvHandler.Home)
mux.HandleFunc("/cv", cvHandler.CVContent)
mux.HandleFunc("/health", healthHandler.Check)
// Protected PDF endpoint with rate limiting (3 requests/minute per IP)
pdfRateLimiter := middleware.NewRateLimiter(3, 1*time.Minute)
protectedPDFHandler := middleware.OriginChecker(
pdfRateLimiter.Middleware(
http.HandlerFunc(cvHandler.ExportPDF),
),
)
mux.Handle("/export/pdf", protectedPDFHandler)
// Static files with cache control
staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("static")))
mux.Handle("/static/", middleware.CacheControl(staticHandler))
// Apply comprehensive middleware chain
handler := middleware.Recovery(
middleware.Logger(
middleware.SecurityHeaders(mux),
),
)
return handler
}