mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-19 14:04:08 +01:00
feat: add weather fetching service
This commit is contained in:
parent
2c0a628613
commit
07e66c753d
@ -12,6 +12,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.7.2
|
github.com/jackc/pgx/v5 v5.7.2
|
||||||
github.com/joho/godotenv v1.5.1
|
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/pressly/goose/v3 v3.24.1
|
||||||
github.com/rabbitmq/amqp091-go v1.10.0
|
github.com/rabbitmq/amqp091-go v1.10.0
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
|
|||||||
@ -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/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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
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=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
|||||||
52
backend/internal/services/weather/cached_fetcher.go
Normal file
52
backend/internal/services/weather/cached_fetcher.go
Normal file
@ -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
|
||||||
|
}
|
||||||
140
backend/internal/services/weather/openweathermap_fetcher.go
Normal file
140
backend/internal/services/weather/openweathermap_fetcher.go
Normal file
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user