This commit is contained in:
2026-01-03 14:16:16 -08:00
commit 1f0e678d47
71 changed files with 16127 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
package server
import (
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
)
// RateLimiter provides per-IP rate limiting
type RateLimiter struct {
limiters map[string]*rate.Limiter
mu sync.RWMutex
rate rate.Limit
burst int
}
// NewRateLimiter creates a new rate limiter
// rate is requests per second, burst is max burst size
func NewRateLimiter(r float64, burst int) *RateLimiter {
return &RateLimiter{
limiters: make(map[string]*rate.Limiter),
rate: rate.Limit(r),
burst: burst,
}
}
// getLimiter returns the rate limiter for a given IP, creating one if needed
func (rl *RateLimiter) getLimiter(ip string) *rate.Limiter {
rl.mu.RLock()
limiter, exists := rl.limiters[ip]
rl.mu.RUnlock()
if exists {
return limiter
}
rl.mu.Lock()
defer rl.mu.Unlock()
// Double-check after acquiring write lock
if limiter, exists = rl.limiters[ip]; exists {
return limiter
}
limiter = rate.NewLimiter(rl.rate, rl.burst)
rl.limiters[ip] = limiter
return limiter
}
// Middleware returns a middleware handler for rate limiting
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get client IP (chi's RealIP middleware should have set this)
ip := r.RemoteAddr
limiter := rl.getLimiter(ip)
if !limiter.Allow() {
w.Header().Set("Retry-After", "1")
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// CleanupOldEntries removes stale IP entries periodically
// Call this in a goroutine to prevent memory growth
func (rl *RateLimiter) CleanupOldEntries(interval time.Duration, maxAge time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
// Simple cleanup: just reset the map periodically
// In a more sophisticated implementation, you'd track last access time
if len(rl.limiters) > 10000 {
rl.limiters = make(map[string]*rate.Limiter)
}
rl.mu.Unlock()
}
}

View File

@@ -0,0 +1,73 @@
package server
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
)
// RouteHandler defines the interface for route handlers
type RouteHandler interface {
Health(w http.ResponseWriter, r *http.Request)
GetCurrentWeather(w http.ResponseWriter, r *http.Request)
GetForecast(w http.ResponseWriter, r *http.Request)
GetHistorical(w http.ResponseWriter, r *http.Request)
AssessConditions(w http.ResponseWriter, r *http.Request)
}
// SetupRoutes configures all API routes
func (s *Server) SetupRoutes(handler RouteHandler) {
// Health check endpoint
s.router.Get("/api/health", handler.Health)
// API v1 routes
s.router.Route("/api/v1", func(r chi.Router) {
// Weather routes
r.Route("/weather", func(r chi.Router) {
r.Get("/current", handler.GetCurrentWeather)
r.Get("/forecast", handler.GetForecast)
r.Get("/historical", handler.GetHistorical)
r.Post("/assess", handler.AssessConditions)
})
})
}
// Response helpers
// JSONResponse is a generic JSON response structure
type JSONResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// RespondJSON writes a JSON response
func RespondJSON(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
response := JSONResponse{
Success: statusCode >= 200 && statusCode < 300,
Data: data,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// RespondError writes a JSON error response
func RespondError(w http.ResponseWriter, statusCode int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
response := JSONResponse{
Success: false,
Error: message,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,132 @@
package server
import (
"context"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
// Server represents the HTTP server
type Server struct {
router *chi.Mux
logger *slog.Logger
addr string
httpServer *http.Server
}
// New creates a new HTTP server with chi router and CORS enabled
func New(addr string, logger *slog.Logger) *Server {
s := &Server{
router: chi.NewRouter(),
logger: logger,
addr: addr,
}
s.setupMiddleware()
return s
}
// setupMiddleware configures all middleware for the router
func (s *Server) setupMiddleware() {
// Request ID middleware
s.router.Use(middleware.RequestID)
// Real IP middleware
s.router.Use(middleware.RealIP)
// Rate limiting: 10 requests/second with burst of 30
// Generous limits since Cloudflare handles most protection
rateLimiter := NewRateLimiter(10, 30)
s.router.Use(rateLimiter.Middleware)
// Structured logging middleware
s.router.Use(s.loggingMiddleware)
// Recover from panics
s.router.Use(middleware.Recoverer)
// Request timeout
s.router.Use(middleware.Timeout(60 * time.Second))
// CORS configuration
s.router.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{
"https://paragliding.scottyah.com",
"http://localhost:3000",
"http://localhost:5173",
},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
// Compress responses
s.router.Use(middleware.Compress(5))
}
// loggingMiddleware logs HTTP requests with structured logging
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
defer func() {
s.logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"bytes", ww.BytesWritten(),
"duration", time.Since(start).String(),
"request_id", middleware.GetReqID(r.Context()),
)
}()
next.ServeHTTP(ww, r)
})
}
// Router returns the chi router
func (s *Server) Router() *chi.Mux {
return s.router
}
// Start starts the HTTP server
func (s *Server) Start() error {
s.logger.Info("starting HTTP server", "addr", s.addr)
s.httpServer = &http.Server{
Addr: s.addr,
Handler: s.router,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("server failed: %w", err)
}
return nil
}
// Shutdown gracefully shuts down the server
func (s *Server) Shutdown(ctx context.Context) error {
s.logger.Info("shutting down HTTP server")
if s.httpServer == nil {
return nil
}
return s.httpServer.Shutdown(ctx)
}