diff --git a/controller/controller.go b/controller/controller.go index f903517..05906a6 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -47,6 +47,12 @@ func fmtWind(windSpeed string, isImperial bool) string { return fmt.Sprintf("%.1f km/h", (parsedSpeed * 3.6)) } +func fmtKey(key string) string { + // Format cache/database keys by replacing whitespaces with '+' token + // and making them uppercase + return strings.ToUpper(strings.ReplaceAll(key, " ", "+")) +} + func deepCopyForecast(original types.Forecast) types.Forecast { // Copy the outer structure fc_copy := original @@ -60,7 +66,7 @@ func deepCopyForecast(original types.Forecast) types.Forecast { return fc_copy } -func GetWeather(res http.ResponseWriter, req *http.Request, cache *types.Cache[types.Weather], vars *types.Variables) { +func GetWeather(res http.ResponseWriter, req *http.Request, cache *types.Cache[types.Weather], statDB *types.StatDB, vars *types.Variables) { if req.Method != http.MethodGet { jsonError(res, "error", "method not allowed", http.StatusMethodNotAllowed) return @@ -73,7 +79,7 @@ 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") - cachedValue, found := cache.GetEntry(cityName, vars.TimeToLive) + cachedValue, found := cache.GetEntry(fmtKey(cityName), vars.TimeToLive) if found { // Format weather object and then return it cachedValue.Temperature = fmtTemperature(cachedValue.Temperature, isImperial) @@ -96,7 +102,10 @@ func GetWeather(res http.ResponseWriter, req *http.Request, cache *types.Cache[t } // Add result to cache - cache.AddEntry(weather, cityName) + cache.AddEntry(weather, fmtKey(cityName)) + + // Insert new statistic entry into the statistics database + statDB.AddStatistic(fmtKey(cityName), weather) // Format weather object and then return it weather.Temperature = fmtTemperature(weather.Temperature, isImperial) @@ -119,7 +128,7 @@ 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") - cachedValue, found := cache.GetEntry(cityName, vars.TimeToLive) + cachedValue, found := cache.GetEntry(fmtKey(cityName), vars.TimeToLive) if found { // Format metrics object and then return it cachedValue.Humidity = fmt.Sprintf("%s%%", cachedValue.Humidity) @@ -144,7 +153,7 @@ func GetMetrics(res http.ResponseWriter, req *http.Request, cache *types.Cache[t } // Add result to cache - cache.AddEntry(metrics, cityName) + cache.AddEntry(metrics, fmtKey(cityName)) // Format metrics object and then return it metrics.Humidity = fmt.Sprintf("%s%%", metrics.Humidity) @@ -169,7 +178,7 @@ func GetWind(res http.ResponseWriter, req *http.Request, cache *types.Cache[type // Check whether the 'i' parameter(imperial mode) is specified isImperial := req.URL.Query().Has("i") - cachedValue, found := cache.GetEntry(cityName, vars.TimeToLive) + cachedValue, found := cache.GetEntry(fmtKey(cityName), vars.TimeToLive) if found { // Format wind object and then return it cachedValue.Speed = fmtWind(cachedValue.Speed, isImperial) @@ -191,7 +200,7 @@ func GetWind(res http.ResponseWriter, req *http.Request, cache *types.Cache[type } // Add result to cache - cache.AddEntry(wind, cityName) + cache.AddEntry(wind, fmtKey(cityName)) // Format wind object and then return it wind.Speed = fmtWind(wind.Speed, isImperial) @@ -213,7 +222,7 @@ func GetForecast(res http.ResponseWriter, req *http.Request, cache *types.Cache[ // Check whether the 'i' parameter(imperial mode) is specified isImperial := req.URL.Query().Has("i") - cachedValue, found := cache.GetEntry(cityName, vars.TimeToLive) + cachedValue, found := cache.GetEntry(fmtKey(cityName), vars.TimeToLive) if found { forecast := deepCopyForecast(cachedValue) @@ -244,7 +253,7 @@ func GetForecast(res http.ResponseWriter, req *http.Request, cache *types.Cache[ } // Add result to cache - cache.AddEntry(deepCopyForecast(forecast), cityName) + cache.AddEntry(deepCopyForecast(forecast), fmtKey(cityName)) // Format forecast object and then return it for idx := range forecast.Forecast { @@ -289,3 +298,23 @@ func GetMoon(res http.ResponseWriter, req *http.Request, cache *types.CacheEntit jsonValue(res, moon) } } + +func GetStatistics(res http.ResponseWriter, req *http.Request, statDB *types.StatDB) { + if req.Method != http.MethodGet { + jsonError(res, "error", "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract city name from '/stats/:city' + path := strings.TrimPrefix(req.URL.Path, "/stats/") + cityName := strings.Trim(path, "/") // Remove trailing slash if present + + // Get city statistics + stats, err := model.GetStatistics(fmtKey(cityName), statDB) + if err != nil { + jsonError(res, "error", err.Error(), http.StatusBadRequest) + return + } + + jsonValue(res, stats) +} diff --git a/main.go b/main.go index b91c64e..dacb0ab 100644 --- a/main.go +++ b/main.go @@ -23,8 +23,9 @@ func main() { log.Fatalf("Environment variables not set") } - // Initialize cache and vars + // Initialize cache, statDB and vars cache := types.InitCache() + statDB := types.InitDB() vars := types.Variables{ Token: token, TimeToLive: int8(ttl), @@ -32,7 +33,7 @@ func main() { // API endpoints http.HandleFunc("/weather/", func(res http.ResponseWriter, req *http.Request) { - controller.GetWeather(res, req, &cache.WeatherCache, &vars) + controller.GetWeather(res, req, &cache.WeatherCache, statDB, &vars) }) http.HandleFunc("/metrics/", func(res http.ResponseWriter, req *http.Request) { diff --git a/model/statisticsModel.go b/model/statisticsModel.go new file mode 100644 index 0000000..f33387c --- /dev/null +++ b/model/statisticsModel.go @@ -0,0 +1,17 @@ +package model + +import ( + "errors" + + "github.com/ceticamarco/zephyr/types" +) + +func GetStatistics(cityName string, statDB *types.StatDB) (types.StatResult, error) { + // Check whether there are sufficient and updated records for the given location + if statDB.IsKeyInvalid(cityName) { + return types.StatResult{}, errors.New("Insufficient or outdated data to perform statistical analysis") + } + // TODO: we have enough data, do the math! + + return types.StatResult{}, nil +} diff --git a/types/statDB.go b/types/statDB.go new file mode 100644 index 0000000..9342cf1 --- /dev/null +++ b/types/statDB.go @@ -0,0 +1,52 @@ +package types + +import ( + "fmt" + "strings" + "time" +) + +// StatDB data type, representing a mapping between a location and its weather +type StatDB struct { + db map[string]Weather +} + +func InitDB() *StatDB { + return &StatDB{ + db: make(map[string]Weather), + } +} + +func (statDB *StatDB) AddStatistic(cityName string, weather Weather) { + key := fmt.Sprintf("%s@%s", weather.Date.Date, cityName) + + // Insert weather statistic into the database only if it isn't present + if _, isPresent := statDB.db[key]; isPresent { + return + } + + statDB.db[key] = weather +} + +func (statDB *StatDB) IsKeyInvalid(key string) bool { + // A key is invalid if it has less than 2 entries within the last 2 days + threshold := time.Now().AddDate(0, 0, -2) + + var validKeys uint = 0 + for storedKey, record := range statDB.db { + if !strings.HasSuffix(storedKey, key) { + continue + } + + if !record.Date.Date.Before(threshold) { + validKeys++ + + // Early skip if we already found two valid keys + if validKeys >= 2 { + return false + } + } + } + + return true +} diff --git a/types/statistics.go b/types/statistics.go new file mode 100644 index 0000000..1ceb974 --- /dev/null +++ b/types/statistics.go @@ -0,0 +1,21 @@ +package types + +// The WeatherAnomaly data type, representing +// skewed meteorological events +type WeatherAnomaly struct { + Date ZephyrDate `json:"date"` + Temp float64 `json:"temperature"` +} + +// The StatResult data type, representing weather statistics +// of past meteorological events +type StatResult struct { + Min float64 `json:"min"` + Max float64 `json:"max"` + Count int `json:"count"` + Mean float64 `json:"mean"` + StdDev float64 `json:"stdDev"` + Median float64 `json:"median"` + Mode float64 `json:"mode"` + Anomaly WeatherAnomaly `json:"anomaly"` +}