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:
Vendored
-189
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user