major refactor of main.go

This commit is contained in:
2026-02-18 15:35:56 -08:00
parent be7e8a3c33
commit b49349eb26
8 changed files with 366 additions and 282 deletions
+36
View File
@@ -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"),
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
module gitea.coupxd.com/cody/go-weather module go-weather
go 1.25.6 go 1.25.6
+32
View File
@@ -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"})
}
+69
View File
@@ -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)
}
+42 -280
View File
@@ -2,332 +2,94 @@ package main
import ( import (
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
_ "github.com/lib/pq"
"go-weather/config"
"go-weather/handlers"
"go-weather/services"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"time" "time"
_ "github.com/lib/pq"
) )
type WeatherResponse struct { func startPoller(db *sql.DB, ws *services.WeatherService, defaultCity string, interval time.Duration) {
Name string `json:"name"` // Use the service
Main struct { weather, err := ws.FetchWeather(defaultCity)
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)
if err != nil { if err != nil {
log.Printf("poller: fetch failed: %v", err) log.Printf("poller: fetch failed: %v", err)
} else { } else {
id, err := saveReading(db, weather) ds := services.NewDatabaseService(db)
id, err := ds.SaveReading(weather)
if err != nil { if err != nil {
log.Printf("poller: save failed %v", err) log.Printf("poller: save failed %v", err)
} else { } else {
log.Printf("poller: saved reading #%d for %s (%.1f F)", id, weather.Name, weather.Main.Temp) log.Printf("poller: saved reading #%d for %s (%.1f F)", id, weather.Name, weather.Main.Temp)
} }
} }
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for range ticker.C {
weather, err := fetchWeather(apiKey, city) weather, err := ws.FetchWeather(defaultCity)
if err != nil { if err != nil {
log.Printf("poller fetch failed: %v", err) log.Printf("poller fetch failed: %v", err)
continue // don't crash continue
} }
ds := services.NewDatabaseService(db)
id, err := saveReading(db, weather) id, err := ds.SaveReading(weather)
if err != nil { if err != nil {
log.Printf("poller: save failed: %v", err) log.Printf("poller: save failed: %v", err)
continue continue
} }
log.Printf("poller: saved reading #%d for %s (%.1f F)", id, weather.Name, weather.Main.Temp) log.Printf("poller: saved reading #%d for %s (%.1f F)", id, weather.Name, weather.Main.Temp)
} }
} }
func main() { func main() {
apiKey := getEnv("OPENWEATHERMAP_API_KEY") config := config.Load()
defaultCity := getEnv("WEATHER_CITY") // 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() database, err := services.ConnectDB(connStr)
defer db.Close()
createTable(db)
// Start background polling in a goroutine
go startPoller(db, apiKey, defaultCity, 10*time.Minute)
// 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
}
city := r.URL.Query().Get("city")
if city == "" {
city = defaultCity
}
weather, err := fetchWeather(apiKey, city)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway) log.Fatalf("failed to connect to db: %v", err)
return }
defer database.Close()
databaseService := services.NewDatabaseService(database)
// Setup weather_readings table
if err := databaseService.CreateTable(); err != nil {
log.Fatalf("failed to create table: %v", err)
} }
// note closure // Create services
id, err := saveReading(db, weather) weatherService := services.NewWeatherService(config.APIKey)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json") // Create route handlers
json.NewEncoder(w).Encode(map[string]interface{}{ weatherHandler := handlers.NewWeatherHandler(weatherService, databaseService, config.DefaultCity)
"message": "weather fetched and saved",
"id": id,
"city": weather.Name,
"temp": weather.Main.Temp,
})
})
// GET /health // Start background poller
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { go startPoller(database, weatherService, config.DefaultCity, 10*time.Minute)
// 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)
})
// 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() { go func() {
fmt.Println("server listening on :8080") fmt.Println("server listening on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil { if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("server failed: %v", err) log.Fatalf("server failed: %v", err)
} }
}() }()
// Wait for shutdown
// wait for Ctrl+C
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt) signal.Notify(quit, os.Interrupt)
<-quit <-quit
fmt.Println("\nshutting down...") fmt.Println("\nshutting down...")
} }
+40
View File
@@ -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"`
}
+105
View File
@@ -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
}
+40
View File
@@ -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
}