From b49349eb26c704e4ea5ce70b35e0b4108676cd14 Mon Sep 17 00:00:00 2001 From: Cody Couperus Date: Wed, 18 Feb 2026 15:35:56 -0800 Subject: [PATCH] major refactor of main.go --- config/config.go | 36 +++++ go.mod | 2 +- handlers/health.go | 32 +++++ handlers/weather.go | 69 +++++++++ main.go | 324 ++++++------------------------------------- models/weather.go | 40 ++++++ services/database.go | 105 ++++++++++++++ services/weather.go | 40 ++++++ 8 files changed, 366 insertions(+), 282 deletions(-) create mode 100644 config/config.go create mode 100644 handlers/health.go create mode 100644 handlers/weather.go create mode 100644 models/weather.go create mode 100644 services/database.go create mode 100644 services/weather.go diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..3177819 --- /dev/null +++ b/config/config.go @@ -0,0 +1,36 @@ +package config + +import ( + "log" + "os" +) + +type Config struct { + APIKey string + DefaultCity string + DBHost string + DBPort string + DBUser string + DBPassword string + DBName string +} + +func GetEnv(key string) string { + val := os.Getenv(key) + if val == "" { + log.Fatalf("missing required env var: %s", key) + } + return val +} + +func Load() *Config { + return &Config{ + APIKey: GetEnv("OPENWEATHERMAP_API_KEY"), + DefaultCity: GetEnv("WEATHER_CITY"), + DBHost: GetEnv("DB_HOST"), + DBPort: GetEnv("DB_PORT"), + DBUser: GetEnv("DB_USER"), + DBPassword: GetEnv("DB_PASSWORD"), + DBName: GetEnv("DB_NAME"), + } +} diff --git a/go.mod b/go.mod index 656c433..a07fb80 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module gitea.coupxd.com/cody/go-weather +module go-weather go 1.25.6 diff --git a/handlers/health.go b/handlers/health.go new file mode 100644 index 0000000..3300223 --- /dev/null +++ b/handlers/health.go @@ -0,0 +1,32 @@ +package handlers + +import ( + "encoding/json" + "go-weather/models" + "net/http" +) + +func Health(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.HealthResponse{Status: "ok"}) +} +func LatestTemperature(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.LatestTemperatureResponse{Temp: 69}) +} +func Test(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.TestResponse{Status: "hello from go"}) +} diff --git a/handlers/weather.go b/handlers/weather.go new file mode 100644 index 0000000..dbfb8e1 --- /dev/null +++ b/handlers/weather.go @@ -0,0 +1,69 @@ +package handlers + +import ( + "encoding/json" + "go-weather/services" + "net/http" +) + +type WeatherHandler struct { + weatherService *services.WeatherService + dbService *services.DatabaseService + config struct { + DefaultCity string + } +} + +func NewWeatherHandler(ws *services.WeatherService, ds *services.DatabaseService, defaultCity string) *WeatherHandler { + return &WeatherHandler{ + weatherService: ws, + dbService: ds, + config: struct{ DefaultCity string }{DefaultCity: defaultCity}, + } +} + +func (h *WeatherHandler) FetchWeather(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + city := r.URL.Query().Get("city") + if city == "" { + city = h.config.DefaultCity + } + weather, err := h.weatherService.FetchWeather(city) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + id, err := h.dbService.SaveReading(weather) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "weather fetched and saved", + "id": id, + "city": weather.Name, + "temp": weather.Main.Temp, + }) +} + +func (h *WeatherHandler) GetHistory(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + city := r.URL.Query().Get("city") + if city == "" { + city = h.config.DefaultCity + } + readings, err := h.dbService.GetReadings(city, 25) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(readings) +} diff --git a/main.go b/main.go index 53ec814..3237603 100644 --- a/main.go +++ b/main.go @@ -2,332 +2,94 @@ package main import ( "database/sql" - "encoding/json" "fmt" + _ "github.com/lib/pq" + "go-weather/config" + "go-weather/handlers" + "go-weather/services" "log" "net/http" "os" "os/signal" "time" - - _ "github.com/lib/pq" ) -type WeatherResponse struct { - Name string `json:"name"` - Main struct { - Temp float64 `json:"temp"` - FeelsLike float64 `json:"feels_like"` - Humidity int `json:"humidity"` - } - - Weather []struct { - Description string `json:"description"` - } `json:"weather"` -} - -type Reading struct { - ID int `json:"id"` - City string `json:"city"` - Temp float64 `json:"temp"` - FeelsLike float64 `json:"feels_like"` - Humidity int `json:"humidity"` - Conditions string `json:"conditions"` - FetchedAt time.Time `json:"fetched_at"` -} - -type HealthResponse struct { - Status string `json:"status"` -} - -type TestResponse struct { - Status string `json:"status"` -} - -type LatestTemperatureResponse struct { - Temp int `json:"temp"` -} - - -func getEnv(key string) string { - val := os.Getenv(key) - if val == "" { - log.Fatalf("missing required env var: %s", key) - } - return val -} - -func connectDB() *sql.DB { - connStr := fmt.Sprintf( - "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", - getEnv("DB_HOST"), - getEnv("DB_PORT"), - getEnv("DB_USER"), - getEnv("DB_PASSWORD"), - getEnv("DB_NAME"), - ) - - db, err := sql.Open("postgres", connStr) - if err != nil { - log.Fatalf("failed to connect to db: %v", err) - } - - err = db.Ping() - if err != nil { - log.Fatalf("failed to connect to db: %v", err) - } - - fmt.Println("connected to PostgreSQL") - return db -} - -func createTable(db *sql.DB) { - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS weather_readings ( - id SERIAL PRIMARY KEY, - city TEXT NOT NULL, - temp DOUBLE PRECISION NOT NULL, - feels_like DOUBLE PRECISION NOT NULL, - humidity INTEGER NOT NULL, - conditions TEXT NOT NULL, - fetched_at TIMESTAMP WITH TIME ZONE NOT NULL - ) - `) - - if err != nil { - log.Fatalf("failed to create table: %v", err) - } - fmt.Println("weather_readings table ready") -} - -func saveReading(db *sql.DB, weather WeatherResponse) (int, error) { - conditions := "" - if len(weather.Weather) > 0 { - conditions = weather.Weather[0].Description - } - - var id int - err := db.QueryRow( - `INSERT INTO weather_readings (city, temp, feels_like, humidity, conditions, fetched_at) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id`, - weather.Name, - weather.Main.Temp, - weather.Main.FeelsLike, - weather.Main.Humidity, - conditions, - time.Now(), - ).Scan(&id) - if err != nil { - return 0, fmt.Errorf("failed to save reading: %w", err) - } - return id, nil -} - -func getReadings(db *sql.DB, city string, limit int) ([]Reading, error) { - rows, err := db.Query( - `SELECT id, city, temp, feels_like, humidity, conditions, fetched_at - FROM weather_readings - WHERE city ILIKE $1 - ORDER BY fetched_at DESC - LIMIT $2`, - city, limit, - ) - - if err != nil { - return nil, fmt.Errorf("Query failed: %w", err) - } - defer rows.Close() - - var readings []Reading - for rows.Next() { - var r Reading - err := rows.Scan(&r.ID, &r.City, &r.Temp, &r.FeelsLike, &r.Humidity, &r.Conditions, &r.FetchedAt) - - if err != nil { - return nil, fmt.Errorf("scan failed: %w", err) - } - - readings = append(readings, r) - } - return readings, nil -} - -func fetchWeather(apiKey, city string) (WeatherResponse, error) { - url := fmt.Sprintf( - "https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s&units=imperial", - city, apiKey, - ) - resp, err := http.Get(url) - - if err != nil { - return WeatherResponse{}, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return WeatherResponse{}, fmt.Errorf("API returned status %d", resp.StatusCode) - } - - var weather WeatherResponse - err = json.NewDecoder(resp.Body).Decode(&weather) - if err != nil { - return WeatherResponse{}, fmt.Errorf("failed to parse response: %w", err) - } - return weather, nil -} - -func startPoller(db *sql.DB, apiKey, city string, interval time.Duration) { - weather, err := fetchWeather(apiKey, city) +func startPoller(db *sql.DB, ws *services.WeatherService, defaultCity string, interval time.Duration) { + // Use the service + weather, err := ws.FetchWeather(defaultCity) if err != nil { log.Printf("poller: fetch failed: %v", err) } else { - id, err := saveReading(db, weather) + ds := services.NewDatabaseService(db) + id, err := ds.SaveReading(weather) if err != nil { log.Printf("poller: save failed %v", err) } else { log.Printf("poller: saved reading #%d for %s (%.1f F)", id, weather.Name, weather.Main.Temp) } } - ticker := time.NewTicker(interval) defer ticker.Stop() - for range ticker.C { - weather, err := fetchWeather(apiKey, city) + weather, err := ws.FetchWeather(defaultCity) if err != nil { log.Printf("poller fetch failed: %v", err) - continue // don't crash + continue } - - id, err := saveReading(db, weather) + ds := services.NewDatabaseService(db) + id, err := ds.SaveReading(weather) if err != nil { log.Printf("poller: save failed: %v", err) continue } log.Printf("poller: saved reading #%d for %s (%.1f F)", id, weather.Name, weather.Main.Temp) } - } - func main() { - apiKey := getEnv("OPENWEATHERMAP_API_KEY") - defaultCity := getEnv("WEATHER_CITY") + config := config.Load() + // Connect to DB + connStr := fmt.Sprintf( + "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + config.DBHost, config.DBPort, config.DBUser, config.DBPassword, config.DBName, + ) - db := connectDB() - defer db.Close() + database, err := services.ConnectDB(connStr) + if err != nil { + log.Fatalf("failed to connect to db: %v", err) + } + defer database.Close() + databaseService := services.NewDatabaseService(database) - createTable(db) + // Setup weather_readings table + if err := databaseService.CreateTable(); err != nil { + log.Fatalf("failed to create table: %v", err) + } - // Start background polling in a goroutine - go startPoller(db, apiKey, defaultCity, 10*time.Minute) + // Create services + weatherService := services.NewWeatherService(config.APIKey) - // POST /weather/fetch?city=London - fetch current weather and save it - http.HandleFunc("/weather/fetch", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } + // Create route handlers + weatherHandler := handlers.NewWeatherHandler(weatherService, databaseService, config.DefaultCity) - city := r.URL.Query().Get("city") - if city == "" { - city = defaultCity - } - - weather, err := fetchWeather(apiKey, city) - if err != nil { - http.Error(w, err.Error(), http.StatusBadGateway) - return - } - - // note closure - id, err := saveReading(db, weather) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "message": "weather fetched and saved", - "id": id, - "city": weather.Name, - "temp": weather.Main.Temp, - }) - }) - - // GET /health - http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - // TODO: Check if database is ready as well - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - okStatus := HealthResponse{Status: "ok"} - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(okStatus) - }) - - // GET /latest-temperature - http.HandleFunc("/latest-temperature", func(w http.ResponseWriter, r *http.Request) { - // testing api integration - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - okStatus := LatestTemperatureResponse{Temp: 69} - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(okStatus) - }) - - // GET /test - http.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) { - // testing deployment pipeline - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - okStatus := TestResponse{Status: "hello from go"} - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(okStatus) - }) - - // GET /weather/history?city=London&limit=10 - http.HandleFunc("/weather/history", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } - - city := r.URL.Query().Get("city") - if city == "" { - city = defaultCity - } - - // note closure - readings, err := getReadings(db, city, 25) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(readings) - }) + // Start background poller + go startPoller(database, weatherService, config.DefaultCity, 10*time.Minute) + // Register routes + http.HandleFunc("/weather/fetch", weatherHandler.FetchWeather) + http.HandleFunc("/weather/history", weatherHandler.GetHistory) + http.HandleFunc("/health", handlers.Health) + http.HandleFunc("/latest-temperature", handlers.LatestTemperature) + http.HandleFunc("/test", handlers.Test) + // Start server go func() { fmt.Println("server listening on :8080") if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatalf("server failed: %v", err) } }() - - // wait for Ctrl+C + // Wait for shutdown quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt) <-quit fmt.Println("\nshutting down...") - } diff --git a/models/weather.go b/models/weather.go new file mode 100644 index 0000000..a001a14 --- /dev/null +++ b/models/weather.go @@ -0,0 +1,40 @@ +package models + +import ( + "time" +) + +type WeatherResponse struct { + Name string `json:"name"` + Main struct { + Temp float64 `json:"temp"` + FeelsLike float64 `json:"feels_like"` + Humidity int `json:"humidity"` + } + + Weather []struct { + Description string `json:"description"` + } `json:"weather"` +} + +type Reading struct { + ID int `json:"id"` + City string `json:"city"` + Temp float64 `json:"temp"` + FeelsLike float64 `json:"feels_like"` + Humidity int `json:"humidity"` + Conditions string `json:"conditions"` + FetchedAt time.Time `json:"fetched_at"` +} + +type HealthResponse struct { + Status string `json:"status"` +} + +type TestResponse struct { + Status string `json:"status"` +} + +type LatestTemperatureResponse struct { + Temp int `json:"temp"` +} diff --git a/services/database.go b/services/database.go new file mode 100644 index 0000000..0f38f65 --- /dev/null +++ b/services/database.go @@ -0,0 +1,105 @@ +package services + +import ( + "database/sql" + "fmt" + _ "github.com/lib/pq" + "go-weather/models" + "time" +) + +func ConnectDB(connStr string) (*sql.DB, error) { + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, fmt.Errorf("failed to connect to db: %v", err) + } + + err = db.Ping() + if err != nil { + return nil, fmt.Errorf("failed to connect to db: %v", err) + } + + fmt.Println("connected to PostgreSQL") + return db, nil +} + +type DatabaseService struct { + db *sql.DB +} + +func NewDatabaseService(db *sql.DB) *DatabaseService { + return &DatabaseService{db: db} +} + +func (s *DatabaseService) CreateTable() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS weather_readings ( + id SERIAL PRIMARY KEY, + city TEXT NOT NULL, + temp DOUBLE PRECISION NOT NULL, + feels_like DOUBLE PRECISION NOT NULL, + humidity INTEGER NOT NULL, + conditions TEXT NOT NULL, + fetched_at TIMESTAMP WITH TIME ZONE NOT NULL + ) + `) + + if err != nil { + fmt.Errorf("failed to create table: %v", err) + } + fmt.Println("weather_readings table ready") + return nil +} + +func (s *DatabaseService) SaveReading(weather models.WeatherResponse) (int, error) { + conditions := "" + if len(weather.Weather) > 0 { + conditions = weather.Weather[0].Description + } + + var id int + err := s.db.QueryRow( + `INSERT INTO weather_readings (city, temp, feels_like, humidity, conditions, fetched_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id`, + weather.Name, + weather.Main.Temp, + weather.Main.FeelsLike, + weather.Main.Humidity, + conditions, + time.Now(), + ).Scan(&id) + if err != nil { + return 0, fmt.Errorf("failed to save reading: %w", err) + } + return id, nil +} + +func (s *DatabaseService) GetReadings(city string, limit int) ([]models.Reading, error) { + rows, err := s.db.Query( + `SELECT id, city, temp, feels_like, humidity, conditions, fetched_at + FROM weather_readings + WHERE city ILIKE $1 + ORDER BY fetched_at DESC + LIMIT $2`, + city, limit, + ) + + if err != nil { + return nil, fmt.Errorf("Query failed: %w", err) + } + defer rows.Close() + + var readings []models.Reading + for rows.Next() { + var r models.Reading + err := rows.Scan(&r.ID, &r.City, &r.Temp, &r.FeelsLike, &r.Humidity, &r.Conditions, &r.FetchedAt) + + if err != nil { + return nil, fmt.Errorf("scan failed: %w", err) + } + + readings = append(readings, r) + } + return readings, nil +} diff --git a/services/weather.go b/services/weather.go new file mode 100644 index 0000000..8d98aa5 --- /dev/null +++ b/services/weather.go @@ -0,0 +1,40 @@ +package services + +import ( + "encoding/json" + "fmt" + "go-weather/models" + "net/http" +) + +type WeatherService struct { + apiKey string +} + +func NewWeatherService(apiKey string) *WeatherService { + return &WeatherService{apiKey: apiKey} +} + +func (s *WeatherService) FetchWeather(city string) (models.WeatherResponse, error) { + url := fmt.Sprintf( + "https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s&units=imperial", + city, s.apiKey, + ) + resp, err := http.Get(url) + + if err != nil { + return models.WeatherResponse{}, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return models.WeatherResponse{}, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + var weather models.WeatherResponse + err = json.NewDecoder(resp.Body).Decode(&weather) + if err != nil { + return models.WeatherResponse{}, fmt.Errorf("failed to parse response: %w", err) + } + return weather, nil +}