From 9e419ec7bfdbd3e3e9b5cb3b7c7a4164f2ba6443 Mon Sep 17 00:00:00 2001 From: Marco Cetica Date: Tue, 17 Jun 2025 17:38:31 +0200 Subject: [PATCH] Added wind route and started forecast endpoint --- controller/controller.go | 135 ++++++++++++++++++++++++++++++++++++--- main.go | 8 +++ model/forecastModel.go | 118 ++++++++++++++++++++++++++++++++++ model/weatherModel.go | 4 +- model/windModel.go | 88 +++++++++++++++++++++++++ types/cache.go | 14 ++-- types/forecast.go | 19 ++++++ types/weather.go | 2 +- types/wind.go | 8 +++ 9 files changed, 378 insertions(+), 18 deletions(-) create mode 100644 model/forecastModel.go create mode 100644 model/windModel.go create mode 100644 types/forecast.go create mode 100644 types/wind.go diff --git a/controller/controller.go b/controller/controller.go index 4310c48..531f38b 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -34,6 +34,19 @@ func fmtTemperature(temp string, isImperial bool) string { return fmt.Sprintf("%d°C", int(math.Round(parsedTemp))) } +func fmtWind(windSpeed string, isImperial bool) string { + // Convert wind speed to mph or km/s from m/s + // 1 m/s = 2.23694 mph + // 1 m/s = 3.6 km/h + parsedSpeed, _ := strconv.ParseFloat(windSpeed, 64) + + if isImperial { + return fmt.Sprintf("%.1f mph", (parsedSpeed * 2.23694)) + } + + return fmt.Sprintf("%.1f km/h", (parsedSpeed * 3.6)) +} + func GetWeather(res http.ResponseWriter, req *http.Request, cache *types.Cache[types.Weather], vars *types.Variables) { if req.Method != http.MethodGet { jsonError(res, "error", "method not allowed", http.StatusMethodNotAllowed) @@ -47,13 +60,13 @@ func GetWeather(res http.ResponseWriter, req *http.Request, cache *types.Cache[t // Check whether the 'i' parameter(imperial mode) is specified isImperial := req.URL.Query().Has("i") - weather, found := cache.GetEntry(cityName, vars.TimeToLive) + cachedValue, found := cache.GetEntry(cityName, vars.TimeToLive) if found { // Format weather object and then return it - weather.Temperature = fmtTemperature(weather.Temperature, isImperial) - weather.FeelsLike = fmtTemperature(weather.FeelsLike, isImperial) + cachedValue.Temperature = fmtTemperature(cachedValue.Temperature, isImperial) + cachedValue.FeelsLike = fmtTemperature(cachedValue.FeelsLike, isImperial) - jsonValue(res, weather) + jsonValue(res, cachedValue) } else { // Get city coordinates city, err := model.GetCoordinates(cityName, vars.Token) @@ -93,15 +106,15 @@ func GetMetrics(res http.ResponseWriter, req *http.Request, cache *types.Cache[t // Check whether the 'i' parameter(imperial mode) is specified isImperial := req.URL.Query().Has("i") - metrics, found := cache.GetEntry(cityName, vars.TimeToLive) + cachedValue, found := cache.GetEntry(cityName, vars.TimeToLive) if found { // Format metrics object and then return it - metrics.Humidity = fmt.Sprintf("%s%%", metrics.Humidity) - metrics.Pressure = fmt.Sprintf("%s hPa", metrics.Pressure) - metrics.DewPoint = fmtTemperature(metrics.DewPoint, isImperial) - metrics.Visibility = fmt.Sprintf("%skm", metrics.Visibility) + cachedValue.Humidity = fmt.Sprintf("%s%%", cachedValue.Humidity) + cachedValue.Pressure = fmt.Sprintf("%s hPa", cachedValue.Pressure) + cachedValue.DewPoint = fmtTemperature(cachedValue.DewPoint, isImperial) + cachedValue.Visibility = fmt.Sprintf("%skm", cachedValue.Visibility) - jsonValue(res, metrics) + jsonValue(res, cachedValue) } else { // Get city coordinates city, err := model.GetCoordinates(cityName, vars.Token) @@ -129,3 +142,105 @@ func GetMetrics(res http.ResponseWriter, req *http.Request, cache *types.Cache[t jsonValue(res, metrics) } } + +func GetWind(res http.ResponseWriter, req *http.Request, cache *types.Cache[types.Wind], vars *types.Variables) { + if req.Method != http.MethodGet { + jsonError(res, "error", "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract city name from '/wind/:city' + path := strings.TrimPrefix(req.URL.Path, "/wind/") + cityName := strings.Trim(path, "/") // Remove trailing slash if present + + // Check whether the 'i' parameter(imperial mode) is specified + isImperial := req.URL.Query().Has("i") + + cachedValue, found := cache.GetEntry(cityName, vars.TimeToLive) + if found { + // Format wind object and then return it + cachedValue.Speed = fmtWind(cachedValue.Speed, isImperial) + + jsonValue(res, cachedValue) + } else { + // Get city coordinates + city, err := model.GetCoordinates(cityName, vars.Token) + if err != nil { + jsonError(res, "error", err.Error(), http.StatusBadRequest) + return + } + + // Get city wind + wind, err := model.GetWind(&city, vars.Token) + if err != nil { + jsonError(res, "error", err.Error(), http.StatusBadRequest) + return + } + + // Add result to cache + cache.AddEntry(wind, cityName) + + // Format wind object and then return it + wind.Speed = fmtWind(wind.Speed, isImperial) + + jsonValue(res, wind) + } +} + +func GetForecast(res http.ResponseWriter, req *http.Request, cache *types.Cache[types.Forecast], vars *types.Variables) { + if req.Method != http.MethodGet { + jsonError(res, "error", "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract city name from '/forecast/:city' + path := strings.TrimPrefix(req.URL.Path, "/forecast/") + cityName := strings.Trim(path, "/") // Remove trailing slash if present + + // Check whether the 'i' parameter(imperial mode) is specified + isImperial := req.URL.Query().Has("i") + + cachedValue, found := cache.GetEntry(cityName, vars.TimeToLive) + if found { + // Format forecast object and then return it + for idx := range cachedValue.Forecast { + cachedValue.Forecast[idx].Min = fmtTemperature(cachedValue.Forecast[idx].Min, isImperial) + cachedValue.Forecast[idx].Max = fmtTemperature(cachedValue.Forecast[idx].Max, isImperial) + cachedValue.Forecast[idx].FeelsLike = fmtTemperature(cachedValue.Forecast[idx].FeelsLike, isImperial) + cachedValue.Forecast[idx].Wind.Speed = fmtWind(cachedValue.Forecast[idx].Wind.Speed, isImperial) + } + + jsonValue(res, cachedValue) + } else { + // Get city coordinates + city, err := model.GetCoordinates(cityName, vars.Token) + if err != nil { + jsonError(res, "error", err.Error(), http.StatusBadRequest) + return + } + + // Get city forecast + forecast, err := model.GetForecast(&city, vars.Token) + if err != nil { + jsonError(res, "error", err.Error(), http.StatusBadRequest) + return + } + + // Add result to cache + cache.AddEntry(forecast, cityName) + + // ***************** + // FIXME: formatting 'forecast' alters cached value + // ***************** + + // Format forecast object and then return it + for idx := range forecast.Forecast { + forecast.Forecast[idx].Min = fmtTemperature(forecast.Forecast[idx].Min, isImperial) + forecast.Forecast[idx].Max = fmtTemperature(forecast.Forecast[idx].Max, isImperial) + forecast.Forecast[idx].FeelsLike = fmtTemperature(forecast.Forecast[idx].FeelsLike, isImperial) + forecast.Forecast[idx].Wind.Speed = fmtWind(forecast.Forecast[idx].Wind.Speed, isImperial) + } + + jsonValue(res, forecast) + } +} diff --git a/main.go b/main.go index 77ca8b7..14e614e 100644 --- a/main.go +++ b/main.go @@ -39,6 +39,14 @@ func main() { controller.GetMetrics(res, req, &cache.MetricsCache, &vars) }) + http.HandleFunc("/wind/", func(res http.ResponseWriter, req *http.Request) { + controller.GetWind(res, req, &cache.WindCache, &vars) + }) + + http.HandleFunc("/forecast/", func(res http.ResponseWriter, req *http.Request) { + controller.GetForecast(res, req, &cache.ForecastCache, &vars) + }) + listenAddr := fmt.Sprintf(":%s", port) log.Printf("Server listening on %s", listenAddr) http.ListenAndServe(listenAddr, nil) diff --git a/model/forecastModel.go b/model/forecastModel.go new file mode 100644 index 0000000..13a6f59 --- /dev/null +++ b/model/forecastModel.go @@ -0,0 +1,118 @@ +package model + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/ceticamarco/zephyr/types" +) + +// Structure representing the JSON response +type tempRes struct { + Min float64 `json:"min"` + Max float64 `json:"max"` +} + +type fsRes struct { + Day float64 `json:"day"` +} + +type weatherRes struct { + Title string `json:"main"` + Description string `json:"description"` + Icon string `json:"icon"` +} + +type dailyRes struct { + Temp tempRes `json:"temp"` + FeelsLike fsRes `json:"feels_like"` + Weather []weatherRes `json:"weather"` + WindSpeed float64 `json:"wind_speed"` + WindDeg float64 `json:"wind_deg"` + Timestamp int64 `json:"dt"` +} + +type forecastRes struct { + Daily []dailyRes `json:"daily"` +} + +func getForecastEntity(dailyForecast dailyRes) types.ForecastEntity { + // Format UNIX timestamp as 'YYYY-MM-DD' + utcTime := time.Unix(int64(dailyForecast.Timestamp), 0) + weatherDate := &types.ZephyrDate{Time: utcTime.UTC()} + + // Set condition accordingly to weather description + var condition string + switch dailyForecast.Weather[0].Description { + case "few clouds": + condition = "SunWithCloud" + case "broken clouds": + condition = "CloudWithSun" + default: + condition = dailyForecast.Weather[0].Title + } + + // Get emoji from weather condition + isNight := strings.HasSuffix(dailyForecast.Weather[0].Icon, "n") + emoji := GetEmoji(condition, isNight) + + // Get cardinal direction and wind arrow + windDirection, windArrow := GetCardinalDir(dailyForecast.WindDeg) + + return types.ForecastEntity{ + Date: weatherDate, + Min: strconv.FormatFloat(dailyForecast.Temp.Min, 'f', -1, 64), + Max: strconv.FormatFloat(dailyForecast.Temp.Max, 'f', -1, 64), + Condition: dailyForecast.Weather[0].Title, + Emoji: emoji, + FeelsLike: strconv.FormatFloat(dailyForecast.FeelsLike.Day, 'f', -1, 64), + Wind: types.Wind{ + Arrow: windArrow, + Direction: windDirection, + Speed: strconv.FormatFloat(dailyForecast.WindSpeed, 'f', 2, 64), + }, + } + +} + +func GetForecast(city *types.City, apiKey string) (types.Forecast, error) { + url, err := url.Parse(WTR_URL) + if err != nil { + return types.Forecast{}, err + } + + params := url.Query() + params.Set("lat", strconv.FormatFloat(city.Lat, 'f', -1, 64)) + params.Set("lon", strconv.FormatFloat(city.Lon, 'f', -1, 64)) + params.Set("appid", apiKey) + params.Set("units", "metric") + params.Set("exclude", "current,minutely,hourly,alerts") + + url.RawQuery = params.Encode() + + res, err := http.Get(url.String()) + if err != nil { + return types.Forecast{}, err + } + defer res.Body.Close() + + var forecastRes forecastRes + if err := json.NewDecoder(res.Body).Decode(&forecastRes); err != nil { + return types.Forecast{}, err + } + + // We skip the first element since it represents the current day + // We ignore forecasts after the fourth day + var forecast []types.ForecastEntity + for _, val := range forecastRes.Daily[1:5] { + forecast = append(forecast, getForecastEntity(val)) + } + + return types.Forecast{ + Forecast: forecast, + }, nil +} diff --git a/model/weatherModel.go b/model/weatherModel.go index efc7bdb..493a3cc 100644 --- a/model/weatherModel.go +++ b/model/weatherModel.go @@ -11,7 +11,7 @@ import ( "github.com/ceticamarco/zephyr/types" ) -func getEmoji(condition string, isNight bool) string { +func GetEmoji(condition string, isNight bool) string { switch condition { case "Thunderstorm": return "⛈️" @@ -101,7 +101,7 @@ func GetWeather(city *types.City, apiKey string) (types.Weather, error) { // Get emoji from weather condition isNight := strings.HasSuffix(weather.Current.Weather[0].Icon, "n") - emoji := getEmoji(condition, isNight) + emoji := GetEmoji(condition, isNight) return types.Weather{ Date: weatherDate, diff --git a/model/windModel.go b/model/windModel.go new file mode 100644 index 0000000..2f5fed6 --- /dev/null +++ b/model/windModel.go @@ -0,0 +1,88 @@ +package model + +import ( + "encoding/json" + "math" + "net/http" + "net/url" + "strconv" + + "github.com/ceticamarco/zephyr/types" +) + +func GetCardinalDir(windDeg float64) (string, string) { + // Each cardinal direction represents a segment of 22.5 degrees + cardinalDirections := [16][2]string{ + {"N", "⬇️"}, // 0/360 DEG + {"NNE", "↙️"}, // 22.5 DEG + {"NE", "↙️"}, // 45 DEG + {"ENE", "↙️"}, // 67.5 DEG + {"E", "⬅️"}, // 90 DEG + {"ESE", "↖️"}, // 112.5 DEG + {"SE", "↖️"}, // 135 DEG + {"SSE", "↖️"}, // 157.5 DEG + {"S", "⬆️"}, // 180 DEG + {"SSW", "↗️"}, // 202.5 DEG + {"SW", "↗️"}, // 225 DEG + {"WSW", "↗️"}, // 247.5 DEG + {"W", "➡️"}, // 270 DEG + {"WNW", "↘️"}, // 292.5 DEG + {"NW", "↘️"}, // 315 DEG + {"NNW", "↘️"}, // 337.5 DEG + } + + // Computes "idx ≡ round(wind_deg / 22.5) (mod 16)" + // to ensure that values above 360 degrees or below 0 degrees + // "stay bounded" to the map + idx := int(math.Round(windDeg/22.5)) % 16 + + return cardinalDirections[idx][0], cardinalDirections[idx][1] + +} + +func GetWind(city *types.City, apiKey string) (types.Wind, error) { + url, err := url.Parse(WTR_URL) + if err != nil { + return types.Wind{}, err + } + + params := url.Query() + params.Set("lat", strconv.FormatFloat(city.Lat, 'f', -1, 64)) + params.Set("lon", strconv.FormatFloat(city.Lon, 'f', -1, 64)) + params.Set("appid", apiKey) + params.Set("units", "metric") + params.Set("exclude", "minutely,hourly,daily,alerts") + + url.RawQuery = params.Encode() + + res, err := http.Get(url.String()) + if err != nil { + return types.Wind{}, err + } + + defer res.Body.Close() + + // Structures representing the JSON response + type CurrentRes struct { + Speed float64 `json:"wind_speed"` + Degrees float64 `json:"wind_deg"` + } + + type WindRes struct { + Current CurrentRes `json:"current"` + } + + var windRes WindRes + if err := json.NewDecoder(res.Body).Decode(&windRes); err != nil { + return types.Wind{}, err + } + + // Get cardinal direction and wind arrow + windDirection, windArrow := GetCardinalDir(windRes.Current.Degrees) + + return types.Wind{ + Arrow: windArrow, + Direction: windDirection, + Speed: strconv.FormatFloat(windRes.Current.Speed, 'f', 2, 64), + }, nil +} diff --git a/types/cache.go b/types/cache.go index 87ba732..5603398 100644 --- a/types/cache.go +++ b/types/cache.go @@ -6,7 +6,7 @@ import ( // CacheType, representing the abstract value of a CacheEntity type CacheType interface { - Weather | Metrics + Weather | Metrics | Wind | Forecast } // CacheEntity, representing the value of the cache @@ -22,14 +22,18 @@ type Cache[T CacheType] struct { // Caches, representing a grouping of the various caches type Caches struct { - WeatherCache Cache[Weather] - MetricsCache Cache[Metrics] + WeatherCache Cache[Weather] + MetricsCache Cache[Metrics] + WindCache Cache[Wind] + ForecastCache Cache[Forecast] } func InitCache() *Caches { return &Caches{ - WeatherCache: Cache[Weather]{Data: make(map[string]CacheEntity[Weather])}, - MetricsCache: Cache[Metrics]{Data: make(map[string]CacheEntity[Metrics])}, + WeatherCache: Cache[Weather]{Data: make(map[string]CacheEntity[Weather])}, + MetricsCache: Cache[Metrics]{Data: make(map[string]CacheEntity[Metrics])}, + WindCache: Cache[Wind]{Data: make(map[string]CacheEntity[Wind])}, + ForecastCache: Cache[Forecast]{Data: make(map[string]CacheEntity[Forecast])}, } } diff --git a/types/forecast.go b/types/forecast.go new file mode 100644 index 0000000..c3b1d39 --- /dev/null +++ b/types/forecast.go @@ -0,0 +1,19 @@ +package types + +// The ForecastEntity data type, representing the weather forecast +// of a single day +type ForecastEntity struct { + Date *ZephyrDate `json:"date"` + Min string `json:"min"` + Max string `json:"max"` + Condition string `json:"condition"` + Emoji string `json:"emoji"` + FeelsLike string `json:"feelsLike"` + Wind Wind `json:"wind"` +} + +// The Forecast data type, representing the an set +// of ForecastEntity +type Forecast struct { + Forecast []ForecastEntity +} diff --git a/types/weather.go b/types/weather.go index 2391544..19c23df 100644 --- a/types/weather.go +++ b/types/weather.go @@ -1,6 +1,6 @@ package types -// The Weather data type, representing the weather of a certain city +// The Weather data type, representing the weather of a certain location type Weather struct { Date *ZephyrDate `json:"date"` Temperature string `json:"temperature"` diff --git a/types/wind.go b/types/wind.go new file mode 100644 index 0000000..cf3af73 --- /dev/null +++ b/types/wind.go @@ -0,0 +1,8 @@ +package types + +// The Wind data type, representing the wind of a certain location +type Wind struct { + Arrow string `json:"arrow"` + Direction string `json:"direction"` + Speed string `json:"speed"` +}