import httpx from datetime import datetime from typing import Dict, Any, Optional, List, Tuple from fastapi import HTTPException from tenacity import retry, stop_after_attempt, wait_exponential from app.core.config import settings from app.schemas.weather import WeatherRecordCreate from app.services.cache import cached class OpenWeatherMapClient: def __init__(self): self.api_key = settings.OPENWEATHERMAP_API_KEY self.base_url = settings.OPENWEATHERMAP_API_URL self.headers = { "Accept": "application/json", "Content-Type": "application/json" } @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10) ) async def _make_request(self, endpoint: str, params: Dict[str, Any]) -> Dict[str, Any]: """ Make a request to the OpenWeatherMap API with retry functionality. """ params["appid"] = self.api_key async with httpx.AsyncClient() as client: response = await client.get( f"{self.base_url}/{endpoint}", params=params, headers=self.headers, timeout=30.0 ) if response.status_code != 200: error_detail = response.json().get("message", "Unknown error") raise HTTPException( status_code=response.status_code, detail=f"OpenWeatherMap API error: {error_detail}" ) return response.json() @cached("current_weather_city", expire_seconds=settings.CACHE_EXPIRE_IN_SECONDS) async def get_current_weather_by_city(self, city_name: str, country_code: Optional[str] = None) -> Dict[str, Any]: """ Get current weather for a city by name and optional country code. """ q = city_name if country_code: q = f"{city_name},{country_code}" params = { "q": q, "units": "metric" } return await self._make_request("weather", params) @cached("current_weather_coords", expire_seconds=settings.CACHE_EXPIRE_IN_SECONDS) async def get_current_weather_by_coordinates(self, lat: float, lon: float) -> Dict[str, Any]: """ Get current weather for a location by coordinates. """ params = { "lat": lat, "lon": lon, "units": "metric" } return await self._make_request("weather", params) @cached("forecast_city", expire_seconds=settings.CACHE_EXPIRE_IN_SECONDS) async def get_forecast_by_city(self, city_name: str, country_code: Optional[str] = None) -> Dict[str, Any]: """ Get 5-day forecast for a city by name and optional country code. """ q = city_name if country_code: q = f"{city_name},{country_code}" params = { "q": q, "units": "metric" } return await self._make_request("forecast", params) @cached("forecast_coords", expire_seconds=settings.CACHE_EXPIRE_IN_SECONDS) async def get_forecast_by_coordinates(self, lat: float, lon: float) -> Dict[str, Any]: """ Get 5-day forecast for a location by coordinates. """ params = { "lat": lat, "lon": lon, "units": "metric" } return await self._make_request("forecast", params) @staticmethod def parse_weather_data(data: Dict[str, Any]) -> Tuple[Dict[str, Any], WeatherRecordCreate]: """ Parse OpenWeatherMap API response into our data models. Returns a tuple of (city_data, weather_data) """ # Parse city data city_data = { "name": data.get("name"), "country": data.get("sys", {}).get("country"), "latitude": data.get("coord", {}).get("lat"), "longitude": data.get("coord", {}).get("lon") } # Get the weather data main_data = data.get("main", {}) weather_data = { "temperature": main_data.get("temp"), "feels_like": main_data.get("feels_like"), "humidity": main_data.get("humidity"), "pressure": main_data.get("pressure"), "weather_condition": data.get("weather", [{}])[0].get("main", ""), "weather_description": data.get("weather", [{}])[0].get("description", ""), "wind_speed": data.get("wind", {}).get("speed"), "wind_direction": data.get("wind", {}).get("deg"), "clouds": data.get("clouds", {}).get("all"), "rain_1h": data.get("rain", {}).get("1h"), "snow_1h": data.get("snow", {}).get("1h"), "timestamp": datetime.fromtimestamp(data.get("dt", 0)), } return city_data, WeatherRecordCreate(**weather_data, city_id=0) # City ID will be updated later @staticmethod def parse_forecast_data(data: Dict[str, Any]) -> Tuple[Dict[str, Any], List[WeatherRecordCreate]]: """ Parse OpenWeatherMap forecast API response into our data models. Returns a tuple of (city_data, list_of_forecast_items) """ city_info = data.get("city", {}) # Parse city data city_data = { "name": city_info.get("name"), "country": city_info.get("country"), "latitude": city_info.get("coord", {}).get("lat"), "longitude": city_info.get("coord", {}).get("lon") } # Parse forecast list forecast_items = [] for item in data.get("list", []): main_data = item.get("main", {}) forecast_item = { "temperature": main_data.get("temp"), "feels_like": main_data.get("feels_like"), "humidity": main_data.get("humidity"), "pressure": main_data.get("pressure"), "weather_condition": item.get("weather", [{}])[0].get("main", ""), "weather_description": item.get("weather", [{}])[0].get("description", ""), "wind_speed": item.get("wind", {}).get("speed"), "wind_direction": item.get("wind", {}).get("deg"), "clouds": item.get("clouds", {}).get("all"), "rain_1h": item.get("rain", {}).get("1h"), "snow_1h": item.get("snow", {}).get("1h"), "timestamp": datetime.fromtimestamp(item.get("dt", 0)), } forecast_items.append(WeatherRecordCreate(**forecast_item, city_id=0)) # City ID will be updated later return city_data, forecast_items # Create a singleton instance of the client openweather_client = OpenWeatherMapClient()