diff --git a/backend/go.mod b/backend/go.mod index 7351cc1..38406e5 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,6 +12,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.2 github.com/joho/godotenv v1.5.1 + github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pressly/goose/v3 v3.24.1 github.com/rabbitmq/amqp091-go v1.10.0 github.com/spf13/cobra v1.8.1 diff --git a/backend/go.sum b/backend/go.sum index 720a7d9..1c61c53 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -58,6 +58,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/backend/internal/services/weather/cached_fetcher.go b/backend/internal/services/weather/cached_fetcher.go new file mode 100644 index 0000000..e757fd1 --- /dev/null +++ b/backend/internal/services/weather/cached_fetcher.go @@ -0,0 +1,52 @@ +package weather + +import ( + "context" + "fmt" + "time" + + "log/slog" + + "github.com/forfarm/backend/internal/domain" + "github.com/patrickmn/go-cache" +) + +type CachedWeatherFetcher struct { + next domain.WeatherFetcher + cache *cache.Cache + logger *slog.Logger +} + +func NewCachedWeatherFetcher(fetcher domain.WeatherFetcher, ttl time.Duration, cleanupInterval time.Duration, logger *slog.Logger) domain.WeatherFetcher { + c := cache.New(ttl, cleanupInterval) + return &CachedWeatherFetcher{ + next: fetcher, + cache: c, + logger: logger, + } +} + +func (f *CachedWeatherFetcher) GetCurrentWeatherByCoords(ctx context.Context, lat, lon float64) (*domain.WeatherData, error) { + cacheKey := fmt.Sprintf("weather_coords_%.4f_%.4f", lat, lon) + + if data, found := f.cache.Get(cacheKey); found { + if weatherData, ok := data.(*domain.WeatherData); ok { + return weatherData, nil + } + f.logger.Warn("Invalid data type found in weather cache", "key", cacheKey) + } + + f.logger.Debug("Cache miss for weather data", "key", cacheKey) + + weatherData, err := f.next.GetCurrentWeatherByCoords(ctx, lat, lon) + if err != nil { + return nil, err + } + + if weatherData != nil { + f.cache.Set(cacheKey, weatherData, cache.DefaultExpiration) // Uses the TTL set during cache creation + f.logger.Debug("Stored fetched weather data in cache", "key", cacheKey) + } + + return weatherData, nil +} diff --git a/backend/internal/services/weather/openweathermap_fetcher.go b/backend/internal/services/weather/openweathermap_fetcher.go new file mode 100644 index 0000000..9aaba48 --- /dev/null +++ b/backend/internal/services/weather/openweathermap_fetcher.go @@ -0,0 +1,140 @@ +package weather + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + "time" + + "github.com/forfarm/backend/internal/domain" +) + +const openWeatherMapAPIURL = "https://api.openweathermap.org/data/2.5/weather" + +type openWeatherMapResponse struct { + Coord struct { + Lon float64 `json:"lon"` + Lat float64 `json:"lat"` + } `json:"coord"` + Weather []struct { + ID int `json:"id"` + Main string `json:"main"` + Description string `json:"description"` + Icon string `json:"icon"` + } `json:"weather"` + Base string `json:"base"` + Main struct { + Temp float64 `json:"temp"` // Kelvin by default + FeelsLike float64 `json:"feels_like"` // Kelvin by default + TempMin float64 `json:"temp_min"` // Kelvin by default + TempMax float64 `json:"temp_max"` // Kelvin by default + Pressure int `json:"pressure"` // hPa + Humidity int `json:"humidity"` // % + SeaLevel int `json:"sea_level"` // hPa + GrndLevel int `json:"grnd_level"` // hPa + } `json:"main"` + Visibility int `json:"visibility"` // meters + Wind struct { + Speed float64 `json:"speed"` // meter/sec + Deg int `json:"deg"` // degrees (meteorological) + Gust float64 `json:"gust"` // meter/sec + } `json:"wind"` + Rain struct { + OneH float64 `json:"1h"` // Rain volume for the last 1 hour, mm + } `json:"rain"` + Clouds struct { + All int `json:"all"` // % + } `json:"clouds"` + Dt int64 `json:"dt"` // Time of data calculation, unix, UTC + Sys struct { + Type int `json:"type"` + ID int `json:"id"` + Country string `json:"country"` + Sunrise int64 `json:"sunrise"` // unix, UTC + Sunset int64 `json:"sunset"` // unix, UTC + } `json:"sys"` + Timezone int `json:"timezone"` // Shift in seconds from UTC + ID int `json:"id"` // City ID + Name string `json:"name"` // City name + Cod int `json:"cod"` // Internal parameter +} + +type OpenWeatherMapFetcher struct { + apiKey string + client *http.Client + logger *slog.Logger +} + +func NewOpenWeatherMapFetcher(apiKey string, client *http.Client, logger *slog.Logger) domain.WeatherFetcher { + if client == nil { + client = &http.Client{Timeout: 10 * time.Second} + } + if logger == nil { + logger = slog.Default() + } + return &OpenWeatherMapFetcher{ + apiKey: apiKey, + client: client, + logger: logger, + } +} + +func (f *OpenWeatherMapFetcher) GetCurrentWeatherByCoords(ctx context.Context, lat, lon float64) (*domain.WeatherData, error) { + queryParams := url.Values{} + queryParams.Set("lat", fmt.Sprintf("%.4f", lat)) + queryParams.Set("lon", fmt.Sprintf("%.4f", lon)) + queryParams.Set("appid", f.apiKey) + queryParams.Set("units", "metric") + + fullURL := fmt.Sprintf("%s?%s", openWeatherMapAPIURL, queryParams.Encode()) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + f.logger.Error("Failed to create OpenWeatherMap request", "error", err) + return nil, fmt.Errorf("failed to create weather request: %w", err) + } + + resp, err := f.client.Do(req) + if err != nil { + f.logger.Error("Failed to execute OpenWeatherMap request", "url", fullURL, "error", err) + return nil, fmt.Errorf("failed to fetch weather data: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + f.logger.Error("OpenWeatherMap API returned non-OK status", "url", fullURL, "status_code", resp.StatusCode) + return nil, fmt.Errorf("weather API request failed with status: %s", resp.Status) + } + + var owmResp openWeatherMapResponse + if err := json.NewDecoder(resp.Body).Decode(&owmResp); err != nil { + f.logger.Error("Failed to decode OpenWeatherMap response", "error", err) + return nil, fmt.Errorf("failed to decode weather response: %w", err) + } + + if len(owmResp.Weather) == 0 { + f.logger.Warn("OpenWeatherMap response missing weather details", "lat", lat, "lon", lon) + return nil, fmt.Errorf("weather data description not found in response") + } + + weatherData := &domain.WeatherData{ + Timestamp: time.Unix(owmResp.Dt, 0).UTC(), + TempCelsius: owmResp.Main.Temp, + Humidity: float64(owmResp.Main.Humidity), + Description: owmResp.Weather[0].Description, + Icon: owmResp.Weather[0].Icon, + WindSpeed: owmResp.Wind.Speed, + RainVolume1h: owmResp.Rain.OneH, + } + + f.logger.Debug("Successfully fetched weather data", + "lat", lat, + "lon", lon, + "temp", weatherData.TempCelsius, + "description", weatherData.Description) + + return weatherData, nil +}