diff --git a/cv-site b/cv-site index 03b8730..f44950a 100755 Binary files a/cv-site and b/cv-site differ diff --git a/internal/handlers/benchmarks_test.go b/internal/handlers/benchmarks_test.go new file mode 100644 index 0000000..0172ad0 --- /dev/null +++ b/internal/handlers/benchmarks_test.go @@ -0,0 +1,182 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/juanatsap/cv-site/internal/config" + "github.com/juanatsap/cv-site/internal/templates" +) + +// BenchmarkHome benchmarks the Home handler +func BenchmarkHome(b *testing.B) { + cfg := &config.TemplateConfig{ + Dir: "../../templates", + PartialsDir: "../../templates/partials", + HotReload: false, // Disable hot reload for benchmarks + } + tmplManager, err := templates.NewManager(cfg) + if err != nil { + b.Fatalf("Failed to create template manager: %v", err) + } + + handler := NewCVHandler(tmplManager, "localhost:8080") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest(http.MethodGet, "/?lang=en", nil) + w := httptest.NewRecorder() + handler.Home(w, req) + } +} + +// BenchmarkCVContent benchmarks the CVContent handler +func BenchmarkCVContent(b *testing.B) { + cfg := &config.TemplateConfig{ + Dir: "../../templates", + PartialsDir: "../../templates/partials", + HotReload: false, + } + tmplManager, err := templates.NewManager(cfg) + if err != nil { + b.Fatalf("Failed to create template manager: %v", err) + } + + handler := NewCVHandler(tmplManager, "localhost:8080") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest(http.MethodGet, "/cv?lang=en", nil) + w := httptest.NewRecorder() + handler.CVContent(w, req) + } +} + +// BenchmarkToggleLength benchmarks the ToggleLength handler +func BenchmarkToggleLength(b *testing.B) { + cfg := &config.TemplateConfig{ + Dir: "../../templates", + PartialsDir: "../../templates/partials", + HotReload: false, + } + tmplManager, err := templates.NewManager(cfg) + if err != nil { + b.Fatalf("Failed to create template manager: %v", err) + } + + handler := NewCVHandler(tmplManager, "localhost:8080") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest(http.MethodPost, "/toggle-length", nil) + req.AddCookie(&http.Cookie{Name: "cv-length", Value: "short"}) + w := httptest.NewRecorder() + handler.ToggleLength(w, req) + } +} + +// BenchmarkParsePDFExportRequest benchmarks request parsing +func BenchmarkParsePDFExportRequest(b *testing.B) { + req := httptest.NewRequest(http.MethodGet, "/export-pdf?lang=en&length=long&icons=show&version=with_skills", nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := ParsePDFExportRequest(req) + if err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkPrepareTemplateData benchmarks template data preparation +func BenchmarkPrepareTemplateData(b *testing.B) { + cfg := &config.TemplateConfig{ + Dir: "../../templates", + PartialsDir: "../../templates/partials", + HotReload: false, + } + tmplManager, err := templates.NewManager(cfg) + if err != nil { + b.Fatalf("Failed to create template manager: %v", err) + } + + handler := NewCVHandler(tmplManager, "localhost:8080") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := handler.prepareTemplateData("en") + if err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkResponseTypes benchmarks response creation +func BenchmarkSuccessResponse(b *testing.B) { + data := map[string]interface{}{ + "status": "ok", + "count": 100, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = SuccessResponse(data) + } +} + +func BenchmarkNewErrorResponse(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = NewErrorResponse("INVALID_INPUT", "Invalid request parameter") + } +} + +// BenchmarkParallelHome benchmarks Home handler under parallel load +func BenchmarkParallelHome(b *testing.B) { + cfg := &config.TemplateConfig{ + Dir: "../../templates", + PartialsDir: "../../templates/partials", + HotReload: false, + } + tmplManager, err := templates.NewManager(cfg) + if err != nil { + b.Fatalf("Failed to create template manager: %v", err) + } + + handler := NewCVHandler(tmplManager, "localhost:8080") + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + req := httptest.NewRequest(http.MethodGet, "/?lang=en", nil) + w := httptest.NewRecorder() + handler.Home(w, req) + } + }) +} + +// BenchmarkParallelToggleLength benchmarks toggle under parallel load +func BenchmarkParallelToggleLength(b *testing.B) { + cfg := &config.TemplateConfig{ + Dir: "../../templates", + PartialsDir: "../../templates/partials", + HotReload: false, + } + tmplManager, err := templates.NewManager(cfg) + if err != nil { + b.Fatalf("Failed to create template manager: %v", err) + } + + handler := NewCVHandler(tmplManager, "localhost:8080") + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + req := httptest.NewRequest(http.MethodPost, "/toggle-length", nil) + req.AddCookie(&http.Cookie{Name: "cv-length", Value: "short"}) + w := httptest.NewRecorder() + handler.ToggleLength(w, req) + } + }) +} diff --git a/internal/handlers/errors.go b/internal/handlers/errors.go index f83f689..94256f0 100644 --- a/internal/handlers/errors.go +++ b/internal/handlers/errors.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "fmt" "log" "net/http" ) @@ -141,3 +142,136 @@ func DataLoadError(err error, dataType string) *AppError { true, ) } + +// ============================================================================== +// TYPED ERRORS +// Domain-specific error types for better error handling +// ============================================================================== + +// ErrorCode represents a specific error condition +type ErrorCode string + +const ( + ErrCodeInvalidLanguage ErrorCode = "INVALID_LANGUAGE" + ErrCodeInvalidLength ErrorCode = "INVALID_LENGTH" + ErrCodeInvalidIcons ErrorCode = "INVALID_ICONS" + ErrCodeInvalidTheme ErrorCode = "INVALID_THEME" + ErrCodeInvalidVersion ErrorCode = "INVALID_VERSION" + ErrCodeTemplateNotFound ErrorCode = "TEMPLATE_NOT_FOUND" + ErrCodeTemplateRender ErrorCode = "TEMPLATE_RENDER" + ErrCodeDataLoad ErrorCode = "DATA_LOAD" + ErrCodePDFGeneration ErrorCode = "PDF_GENERATION" + ErrCodeMethodNotAllowed ErrorCode = "METHOD_NOT_ALLOWED" + ErrCodeUnauthorized ErrorCode = "UNAUTHORIZED" + ErrCodeForbidden ErrorCode = "FORBIDDEN" + ErrCodeRateLimitExceeded ErrorCode = "RATE_LIMIT_EXCEEDED" +) + +// DomainError represents a domain-specific error +type DomainError struct { + Code ErrorCode + Message string + Err error + StatusCode int + Field string // Optional field that caused the error +} + +// Error implements the error interface +func (e *DomainError) Error() string { + if e.Err != nil { + return fmt.Sprintf("%s: %v", e.Code, e.Err) + } + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +// Unwrap returns the underlying error +func (e *DomainError) Unwrap() error { + return e.Err +} + +// NewDomainError creates a new domain error +func NewDomainError(code ErrorCode, message string, statusCode int) *DomainError { + return &DomainError{ + Code: code, + Message: message, + StatusCode: statusCode, + } +} + +// WithError adds an underlying error +func (e *DomainError) WithError(err error) *DomainError { + e.Err = err + return e +} + +// WithField adds field information +func (e *DomainError) WithField(field string) *DomainError { + e.Field = field + return e +} + +// Common domain error constructors + +func InvalidLanguageError(lang string) *DomainError { + return NewDomainError( + ErrCodeInvalidLanguage, + fmt.Sprintf("Unsupported language: %s (use 'en' or 'es')", lang), + http.StatusBadRequest, + ).WithField("lang") +} + +func InvalidLengthError(length string) *DomainError { + return NewDomainError( + ErrCodeInvalidLength, + fmt.Sprintf("Unsupported length: %s (use 'short' or 'long')", length), + http.StatusBadRequest, + ).WithField("length") +} + +func InvalidIconsError(icons string) *DomainError { + return NewDomainError( + ErrCodeInvalidIcons, + fmt.Sprintf("Unsupported icons option: %s (use 'show' or 'hide')", icons), + http.StatusBadRequest, + ).WithField("icons") +} + +func InvalidThemeError(theme string) *DomainError { + return NewDomainError( + ErrCodeInvalidTheme, + fmt.Sprintf("Unsupported theme: %s (use 'default' or 'clean')", theme), + http.StatusBadRequest, + ).WithField("theme") +} + +func InvalidVersionError(version string) *DomainError { + return NewDomainError( + ErrCodeInvalidVersion, + fmt.Sprintf("Unsupported version: %s (use 'with_skills' or 'clean')", version), + http.StatusBadRequest, + ).WithField("version") +} + +func PDFGenerationError(err error) *DomainError { + return NewDomainError( + ErrCodePDFGeneration, + "Failed to generate PDF", + http.StatusInternalServerError, + ).WithError(err) +} + +func MethodNotAllowedError(method string) *DomainError { + return NewDomainError( + ErrCodeMethodNotAllowed, + fmt.Sprintf("Method %s not allowed", method), + http.StatusMethodNotAllowed, + ) +} + +func RateLimitError() *DomainError { + return NewDomainError( + ErrCodeRateLimitExceeded, + "Rate limit exceeded. Please try again later.", + http.StatusTooManyRequests, + ) +} diff --git a/internal/handlers/types.go b/internal/handlers/types.go index f75ecb2..5de62db 100644 --- a/internal/handlers/types.go +++ b/internal/handlers/types.go @@ -12,7 +12,7 @@ import ( // LanguageRequest represents a request with language parameter type LanguageRequest struct { - Lang string + Lang string `validate:"required,oneof=en es"` } // ParseLanguageRequest parses and validates language from query parameters @@ -32,10 +32,10 @@ func ParseLanguageRequest(r *http.Request) (*LanguageRequest, error) { // PDFExportRequest represents all parameters for PDF export type PDFExportRequest struct { - Lang string // Language: "en" or "es" - Length string // Length: "short" or "long" - Icons string // Icons: "show" or "hide" - Version string // Version: "with_skills" or "clean" + Lang string `validate:"required,oneof=en es"` // Language: "en" or "es" + Length string `validate:"required,oneof=short long"` // Length: "short" or "long" + Icons string `validate:"required,oneof=show hide"` // Icons: "show" or "hide" + Version string `validate:"required,oneof=with_skills clean"` // Version: "with_skills" or "clean" } // ParsePDFExportRequest parses and validates PDF export parameters @@ -98,3 +98,70 @@ func ParsePreferenceToggleRequest(r *http.Request, defaultLang string) *Preferen return &PreferenceToggleRequest{Lang: lang} } + +// ============================================================================== +// RESPONSE TYPES +// Structured response types for consistent API responses +// ============================================================================== + +// APIResponse is a standardized response wrapper for JSON responses +type APIResponse struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error *ErrorInfo `json:"error,omitempty"` + Meta *MetaInfo `json:"meta,omitempty"` +} + +// ErrorInfo provides structured error information +type ErrorInfo struct { + Code string `json:"code"` // Error code (e.g., "INVALID_LANGUAGE") + Message string `json:"message"` // Human-readable error message + Field string `json:"field,omitempty"` // Field that caused the error + Details string `json:"details,omitempty"` // Additional error details +} + +// MetaInfo provides metadata about the response +type MetaInfo struct { + Timestamp int64 `json:"timestamp,omitempty"` // Unix timestamp + Version string `json:"version,omitempty"` // API version + RequestID string `json:"request_id,omitempty"` // Request tracking ID +} + +// SuccessResponse creates a success response +func SuccessResponse(data interface{}) *APIResponse { + return &APIResponse{ + Success: true, + Data: data, + } +} + +// NewErrorResponse creates an error response +func NewErrorResponse(code, message string) *APIResponse { + return &APIResponse{ + Success: false, + Error: &ErrorInfo{ + Code: code, + Message: message, + }, + } +} + +// ErrorResponseWithField creates an error response with field information +func ErrorResponseWithField(code, message, field string) *APIResponse { + return &APIResponse{ + Success: false, + Error: &ErrorInfo{ + Code: code, + Message: message, + Field: field, + }, + } +} + +// HealthCheckResponse represents health check endpoint response +type HealthCheckResponse struct { + Status string `json:"status"` // "healthy" or "unhealthy" + Version string `json:"version"` // Application version + Uptime int64 `json:"uptime,omitempty"` // Uptime in seconds + Checks map[string]bool `json:"checks,omitempty"` // Component health checks +} diff --git a/internal/middleware/benchmarks_test.go b/internal/middleware/benchmarks_test.go new file mode 100644 index 0000000..9e4afb6 --- /dev/null +++ b/internal/middleware/benchmarks_test.go @@ -0,0 +1,170 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// BenchmarkPreferencesMiddleware benchmarks the middleware +func BenchmarkPreferencesMiddleware(b *testing.B) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = GetPreferences(r) + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := PreferencesMiddleware(handler) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "cv-length", Value: "long"}) + req.AddCookie(&http.Cookie{Name: "cv-icons", Value: "show"}) + req.AddCookie(&http.Cookie{Name: "cv-language", Value: "en"}) + req.AddCookie(&http.Cookie{Name: "cv-theme", Value: "default"}) + req.AddCookie(&http.Cookie{Name: "color-theme", Value: "light"}) + + w := httptest.NewRecorder() + wrappedHandler.ServeHTTP(w, req) + } +} + +// BenchmarkPreferencesMiddlewareWithMigration benchmarks with value migration +func BenchmarkPreferencesMiddlewareWithMigration(b *testing.B) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = GetPreferences(r) + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := PreferencesMiddleware(handler) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "cv-length", Value: "extended"}) // Old value + req.AddCookie(&http.Cookie{Name: "cv-icons", Value: "true"}) // Old value + + w := httptest.NewRecorder() + wrappedHandler.ServeHTTP(w, req) + } +} + +// BenchmarkGetPreferences benchmarks context retrieval +func BenchmarkGetPreferences(b *testing.B) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = GetPreferences(r) + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := PreferencesMiddleware(handler) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "cv-length", Value: "long"}) + w := httptest.NewRecorder() + + // Setup context once + wrappedHandler.ServeHTTP(w, req) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = GetPreferences(req) + } +} + +// BenchmarkContextHelpers benchmarks helper functions +func BenchmarkGetLanguage(b *testing.B) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := PreferencesMiddleware(handler) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "cv-language", Value: "es"}) + w := httptest.NewRecorder() + + // Setup context + wrappedHandler.ServeHTTP(w, req) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = GetLanguage(req) + } +} + +func BenchmarkIsLongCV(b *testing.B) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := PreferencesMiddleware(handler) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "cv-length", Value: "long"}) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = IsLongCV(req) + } +} + +func BenchmarkShowIcons(b *testing.B) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := PreferencesMiddleware(handler) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "cv-icons", Value: "show"}) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = ShowIcons(req) + } +} + +// BenchmarkSetPreferenceCookie benchmarks cookie setting +func BenchmarkSetPreferenceCookie(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + w := httptest.NewRecorder() + SetPreferenceCookie(w, "cv-length", "long") + } +} + +// BenchmarkParallelPreferencesMiddleware benchmarks under parallel load +func BenchmarkParallelPreferencesMiddleware(b *testing.B) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = GetPreferences(r) + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := PreferencesMiddleware(handler) + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "cv-length", Value: "long"}) + w := httptest.NewRecorder() + wrappedHandler.ServeHTTP(w, req) + } + }) +} + +// BenchmarkPreferencesWithoutMiddleware benchmarks fallback path +func BenchmarkPreferencesWithoutMiddleware(b *testing.B) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = GetPreferences(req) + } +} diff --git a/internal/middleware/preferences.go b/internal/middleware/preferences.go index 5d8bd3e..d3183ef 100644 --- a/internal/middleware/preferences.go +++ b/internal/middleware/preferences.go @@ -67,6 +67,76 @@ func GetPreferences(r *http.Request) *Preferences { return prefs } +// ============================================================================== +// CONTEXT HELPER FUNCTIONS +// Convenience functions for accessing specific preference values +// ============================================================================== + +// GetLanguage retrieves the user's language preference +func GetLanguage(r *http.Request) string { + return GetPreferences(r).CVLanguage +} + +// GetCVLength retrieves the user's CV length preference +func GetCVLength(r *http.Request) string { + return GetPreferences(r).CVLength +} + +// GetCVIcons retrieves the user's icon visibility preference +func GetCVIcons(r *http.Request) string { + return GetPreferences(r).CVIcons +} + +// GetCVTheme retrieves the user's CV theme preference +func GetCVTheme(r *http.Request) string { + return GetPreferences(r).CVTheme +} + +// GetColorTheme retrieves the user's color theme preference +func GetColorTheme(r *http.Request) string { + return GetPreferences(r).ColorTheme +} + +// IsLongCV returns true if the user prefers long CV format +func IsLongCV(r *http.Request) bool { + return GetCVLength(r) == "long" +} + +// IsShortCV returns true if the user prefers short CV format +func IsShortCV(r *http.Request) bool { + return GetCVLength(r) == "short" +} + +// ShowIcons returns true if icons should be visible +func ShowIcons(r *http.Request) bool { + return GetCVIcons(r) == "show" +} + +// HideIcons returns true if icons should be hidden +func HideIcons(r *http.Request) bool { + return GetCVIcons(r) == "hide" +} + +// IsCleanTheme returns true if clean theme is selected +func IsCleanTheme(r *http.Request) bool { + return GetCVTheme(r) == "clean" +} + +// IsDefaultTheme returns true if default theme is selected +func IsDefaultTheme(r *http.Request) bool { + return GetCVTheme(r) == "default" +} + +// IsDarkMode returns true if dark mode is enabled +func IsDarkMode(r *http.Request) bool { + return GetColorTheme(r) == "dark" +} + +// IsLightMode returns true if light mode is enabled +func IsLightMode(r *http.Request) bool { + return GetColorTheme(r) == "light" +} + // SetPreferenceCookie sets a preference cookie (1 year expiry) func SetPreferenceCookie(w http.ResponseWriter, name string, value string) { http.SetCookie(w, &http.Cookie{ diff --git a/templates/partials/widgets/download-button.html b/templates/partials/widgets/download-button.html index c4c3363..d9752e3 100644 --- a/templates/partials/widgets/download-button.html +++ b/templates/partials/widgets/download-button.html @@ -2,9 +2,9 @@ {{end}} diff --git a/templates/partials/widgets/zoom-toggle-button.html b/templates/partials/widgets/zoom-toggle-button.html index 37a6bc9..44d0fa0 100644 --- a/templates/partials/widgets/zoom-toggle-button.html +++ b/templates/partials/widgets/zoom-toggle-button.html @@ -2,9 +2,9 @@