init
This commit is contained in:
421
backend/cmd/api/main.go
Normal file
421
backend/cmd/api/main.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/scottyah/paragliding/internal/client"
|
||||
"github.com/scottyah/paragliding/internal/config"
|
||||
"github.com/scottyah/paragliding/internal/database"
|
||||
"github.com/scottyah/paragliding/internal/model"
|
||||
"github.com/scottyah/paragliding/internal/repository"
|
||||
"github.com/scottyah/paragliding/internal/server"
|
||||
"github.com/scottyah/paragliding/internal/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Setup structured logging
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
logger.Error("failed to load configuration", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.Info("configuration loaded",
|
||||
"port", cfg.Port,
|
||||
"location", cfg.LocationName,
|
||||
"fetch_interval", cfg.FetchInterval,
|
||||
)
|
||||
|
||||
// Create context for graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Connect to PostgreSQL with retry
|
||||
dbpool, err := connectDatabaseWithRetry(ctx, cfg.DatabaseURL, logger, 30*time.Second)
|
||||
if err != nil {
|
||||
logger.Error("failed to connect to database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer dbpool.Close()
|
||||
|
||||
logger.Info("connected to database")
|
||||
|
||||
// Run database migrations
|
||||
if err := database.RunMigrations(cfg.DatabaseURL, logger); err != nil {
|
||||
logger.Error("failed to run migrations", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize services
|
||||
weatherClient := client.NewOpenMeteoClient(client.OpenMeteoConfig{
|
||||
Latitude: cfg.LocationLat,
|
||||
Longitude: cfg.LocationLon,
|
||||
Timezone: cfg.Timezone,
|
||||
})
|
||||
|
||||
weatherRepo := repository.NewWeatherRepository(dbpool)
|
||||
assessmentSvc := service.NewAssessmentService()
|
||||
|
||||
// Create weather service (handles caching and API rate limiting)
|
||||
weatherSvc := service.NewWeatherService(service.WeatherServiceConfig{
|
||||
Client: weatherClient,
|
||||
Repo: weatherRepo,
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
// Create HTTP server
|
||||
srv := server.New(cfg.Addr(), logger)
|
||||
|
||||
// Setup routes with handler
|
||||
handler := &Handler{
|
||||
logger: logger,
|
||||
db: dbpool,
|
||||
config: cfg,
|
||||
weatherSvc: weatherSvc,
|
||||
assessmentSvc: assessmentSvc,
|
||||
}
|
||||
srv.SetupRoutes(handler)
|
||||
|
||||
// Start background weather fetcher
|
||||
fetcher := &WeatherFetcher{
|
||||
logger: logger,
|
||||
config: cfg,
|
||||
weatherSvc: weatherSvc,
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
go fetcher.Start(ctx)
|
||||
|
||||
// Start HTTP server in a goroutine
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
serverErrors <- srv.Start()
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal or server error
|
||||
shutdown := make(chan os.Signal, 1)
|
||||
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case err := <-serverErrors:
|
||||
logger.Error("server error", "error", err)
|
||||
case sig := <-shutdown:
|
||||
logger.Info("shutdown signal received", "signal", sig)
|
||||
|
||||
// Stop background fetcher
|
||||
close(fetcher.stopChan)
|
||||
|
||||
// Give outstanding requests time to complete
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
logger.Error("graceful shutdown failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.Info("server stopped gracefully")
|
||||
}
|
||||
}
|
||||
|
||||
// connectDatabaseWithRetry establishes a connection pool to PostgreSQL with exponential backoff
|
||||
func connectDatabaseWithRetry(ctx context.Context, databaseURL string, logger *slog.Logger, maxWait time.Duration) (*pgxpool.Pool, error) {
|
||||
pgConfig, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse database URL: %w", err)
|
||||
}
|
||||
|
||||
// Set connection pool settings
|
||||
pgConfig.MaxConns = 25
|
||||
pgConfig.MinConns = 5
|
||||
pgConfig.MaxConnLifetime = time.Hour
|
||||
pgConfig.MaxConnIdleTime = 30 * time.Minute
|
||||
pgConfig.HealthCheckPeriod = time.Minute
|
||||
|
||||
// Exponential backoff retry
|
||||
var pool *pgxpool.Pool
|
||||
backoff := 1 * time.Second
|
||||
maxBackoff := 8 * time.Second
|
||||
deadline := time.Now().Add(maxWait)
|
||||
|
||||
for {
|
||||
pool, err = pgxpool.NewWithConfig(ctx, pgConfig)
|
||||
if err == nil {
|
||||
// Verify connection
|
||||
if pingErr := pool.Ping(ctx); pingErr == nil {
|
||||
return pool, nil
|
||||
} else {
|
||||
pool.Close()
|
||||
err = pingErr
|
||||
}
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("failed to connect to database after %v: %w", maxWait, err)
|
||||
}
|
||||
|
||||
logger.Warn("database connection failed, retrying",
|
||||
"error", err,
|
||||
"retry_in", backoff,
|
||||
)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
|
||||
// Exponential backoff with cap
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handler implements the RouteHandler interface
|
||||
type Handler struct {
|
||||
logger *slog.Logger
|
||||
db *pgxpool.Pool
|
||||
config *config.Config
|
||||
weatherSvc *service.WeatherService
|
||||
assessmentSvc *service.AssessmentService
|
||||
}
|
||||
|
||||
// Health handles health check requests
|
||||
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check database connectivity
|
||||
if err := h.db.Ping(ctx); err != nil {
|
||||
h.logger.Error("health check failed", "error", err)
|
||||
server.RespondError(w, 503, "database unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
server.RespondJSON(w, 200, map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"timestamp": time.Now().UTC(),
|
||||
"location": h.config.LocationName,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCurrentWeather handles current weather requests
|
||||
func (h *Handler) GetCurrentWeather(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Get current weather from service (handles caching, DB, and rate-limited API fallback)
|
||||
weatherData, err := h.weatherSvc.GetCurrentWeather(ctx)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get current weather", "error", err)
|
||||
server.RespondError(w, 500, "failed to fetch weather data")
|
||||
return
|
||||
}
|
||||
|
||||
if len(weatherData.Points) == 0 {
|
||||
server.RespondError(w, 500, "no current weather data available")
|
||||
return
|
||||
}
|
||||
|
||||
current := weatherData.Points[0]
|
||||
|
||||
// Get all points for assessment
|
||||
allPoints, err := h.weatherSvc.GetAllPoints(ctx)
|
||||
if err != nil {
|
||||
h.logger.Warn("failed to get points for assessment", "error", err)
|
||||
allPoints = weatherData.Points
|
||||
}
|
||||
|
||||
// Get default thresholds and assess conditions
|
||||
thresholds := model.DefaultThresholds()
|
||||
assessment := h.assessmentSvc.Evaluate(allPoints, thresholds)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"timestamp": weatherData.FetchedAt.UTC(),
|
||||
"location": map[string]interface{}{
|
||||
"name": h.config.LocationName,
|
||||
"lat": h.config.LocationLat,
|
||||
"lon": h.config.LocationLon,
|
||||
},
|
||||
"current": map[string]interface{}{
|
||||
"windSpeed": current.WindSpeedMPH,
|
||||
"windDirection": current.WindDirection,
|
||||
"windGust": current.WindGustMPH,
|
||||
"time": current.Time,
|
||||
},
|
||||
"assessment": assessment,
|
||||
"source": weatherData.Source,
|
||||
}
|
||||
|
||||
server.RespondJSON(w, 200, response)
|
||||
}
|
||||
|
||||
// GetForecast handles weather forecast requests
|
||||
func (h *Handler) GetForecast(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Get forecast from service (handles caching, DB, and rate-limited API fallback)
|
||||
weatherData, err := h.weatherSvc.GetForecast(ctx)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get forecast", "error", err)
|
||||
server.RespondError(w, 500, "failed to fetch forecast data")
|
||||
return
|
||||
}
|
||||
|
||||
// Get default thresholds
|
||||
thresholds := model.DefaultThresholds()
|
||||
|
||||
// Find flyable windows
|
||||
windows := h.assessmentSvc.FindFlyableWindows(weatherData.Points, thresholds)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"generated": weatherData.FetchedAt.UTC(),
|
||||
"location": map[string]interface{}{
|
||||
"name": h.config.LocationName,
|
||||
"lat": h.config.LocationLat,
|
||||
"lon": h.config.LocationLon,
|
||||
},
|
||||
"forecast": weatherData.Points,
|
||||
"flyableWindows": windows,
|
||||
"defaultThresholds": thresholds,
|
||||
"source": weatherData.Source,
|
||||
}
|
||||
|
||||
server.RespondJSON(w, 200, response)
|
||||
}
|
||||
|
||||
// GetHistorical handles historical weather requests
|
||||
func (h *Handler) GetHistorical(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Parse date from query param (default: yesterday)
|
||||
dateStr := r.URL.Query().Get("date")
|
||||
var date time.Time
|
||||
if dateStr == "" {
|
||||
date = time.Now().AddDate(0, 0, -1)
|
||||
} else {
|
||||
var err error
|
||||
date, err = time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
server.RespondError(w, 400, "invalid date format, use YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get historical data from service (handles caching and DB lookup)
|
||||
weatherData, err := h.weatherSvc.GetHistorical(ctx, date)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get historical data", "error", err)
|
||||
server.RespondError(w, 500, "failed to fetch historical data")
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"date": date.Format("2006-01-02"),
|
||||
"data": weatherData.Points,
|
||||
"source": weatherData.Source,
|
||||
}
|
||||
|
||||
server.RespondJSON(w, 200, response)
|
||||
}
|
||||
|
||||
// AssessConditions handles paragliding condition assessment requests
|
||||
func (h *Handler) AssessConditions(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Limit request body size
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB max
|
||||
|
||||
// Parse thresholds from request body
|
||||
var req struct {
|
||||
Thresholds model.Thresholds `json:"thresholds"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// Use default thresholds if not provided or invalid
|
||||
req.Thresholds = model.DefaultThresholds()
|
||||
}
|
||||
|
||||
// Get weather data from service (DB-first, no API call triggered here)
|
||||
points, err := h.weatherSvc.GetAllPoints(ctx)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get weather for assessment", "error", err)
|
||||
server.RespondError(w, 500, "failed to fetch weather data")
|
||||
return
|
||||
}
|
||||
|
||||
// Run assessment with provided thresholds
|
||||
assessment := h.assessmentSvc.Evaluate(points, req.Thresholds)
|
||||
windows := h.assessmentSvc.FindFlyableWindows(points, req.Thresholds)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"assessment": assessment,
|
||||
"flyableWindows": windows,
|
||||
"thresholds": req.Thresholds,
|
||||
}
|
||||
|
||||
server.RespondJSON(w, 200, response)
|
||||
}
|
||||
|
||||
// WeatherFetcher runs background weather fetching
|
||||
type WeatherFetcher struct {
|
||||
logger *slog.Logger
|
||||
config *config.Config
|
||||
weatherSvc *service.WeatherService
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
// Start begins the background weather fetching loop
|
||||
func (f *WeatherFetcher) Start(ctx context.Context) {
|
||||
f.logger.Info("starting weather fetcher", "interval", f.config.FetchInterval)
|
||||
|
||||
ticker := time.NewTicker(f.config.FetchInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Fetch immediately on startup
|
||||
f.fetch(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
f.fetch(ctx)
|
||||
case <-f.stopChan:
|
||||
f.logger.Info("stopping weather fetcher")
|
||||
return
|
||||
case <-ctx.Done():
|
||||
f.logger.Info("weather fetcher context cancelled")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetch performs the actual weather data fetching via the weather service
|
||||
func (f *WeatherFetcher) fetch(ctx context.Context) {
|
||||
f.logger.Info("fetching weather data",
|
||||
"lat", f.config.LocationLat,
|
||||
"lon", f.config.LocationLon,
|
||||
)
|
||||
|
||||
// Use weather service's FetchFromAPI (bypasses rate limiting for scheduled fetches)
|
||||
points, err := f.weatherSvc.FetchFromAPI(ctx)
|
||||
if err != nil {
|
||||
f.logger.Error("failed to fetch weather data", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
f.logger.Info("weather data updated successfully", "points", len(points))
|
||||
}
|
||||
Reference in New Issue
Block a user