From 1468af1391f6b499e293c3f5370daa54945df172 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Mon, 12 May 2025 14:26:44 +0000 Subject: [PATCH] Add Weather Data API with OpenWeatherMap integration - Created FastAPI application with SQLite database integration - Implemented OpenWeatherMap client with caching - Added endpoints for current weather, forecasts, and history - Included comprehensive error handling and validation - Set up Alembic migrations - Created detailed README with usage examples generated with BackendIM... (backend.im) --- README.md | 102 ++++++++- alembic.ini | 102 +++++++++ alembic/env.py | 84 ++++++++ alembic/script.py.mako | 24 +++ .../f9a8b7c6d5e4_initial_migration.py | 66 ++++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/endpoints/__init__.py | 0 app/api/endpoints/weather.py | 193 ++++++++++++++++++ app/api/routes.py | 5 + app/core/__init__.py | 0 app/core/config.py | 33 +++ app/core/exceptions.py | 143 +++++++++++++ app/crud/__init__.py | 0 app/crud/base.py | 64 ++++++ app/crud/weather.py | 54 +++++ app/database/__init__.py | 0 app/database/session.py | 22 ++ app/models/__init__.py | 0 app/models/weather.py | 45 ++++ app/schemas/__init__.py | 0 app/schemas/weather.py | 78 +++++++ app/services/__init__.py | 0 app/services/cache.py | 99 +++++++++ app/services/openweather.py | 177 ++++++++++++++++ app/tests/__init__.py | 0 main.py | 30 +++ requirements.txt | 12 ++ 28 files changed, 1331 insertions(+), 2 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/f9a8b7c6d5e4_initial_migration.py create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/endpoints/__init__.py create mode 100644 app/api/endpoints/weather.py create mode 100644 app/api/routes.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/exceptions.py create mode 100644 app/crud/__init__.py create mode 100644 app/crud/base.py create mode 100644 app/crud/weather.py create mode 100644 app/database/__init__.py create mode 100644 app/database/session.py create mode 100644 app/models/__init__.py create mode 100644 app/models/weather.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/weather.py create mode 100644 app/services/__init__.py create mode 100644 app/services/cache.py create mode 100644 app/services/openweather.py create mode 100644 app/tests/__init__.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/README.md b/README.md index e8acfba..ac666ac 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,101 @@ -# FastAPI Application +# Weather Data API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A powerful and efficient REST API for retrieving weather data from OpenWeatherMap. Built with FastAPI and SQLite. + +## Features + +- **Current Weather Data**: Get real-time weather data for any location by city name or coordinates +- **Weather Forecasts**: Retrieve 5-day weather forecasts +- **Historical Data**: Access historical weather data stored in the database +- **Built-in Caching**: Reduces API calls to OpenWeatherMap with intelligent caching +- **Error Handling**: Comprehensive error handling with clear error messages +- **Database Integration**: Stores weather data in SQLite for persistence +- **Swagger Documentation**: Interactive API documentation available at `/docs` +- **Health Check Endpoint**: Easily verify API operational status + +## API Endpoints + +- `GET /api/v1/weather/current`: Get current weather data +- `GET /api/v1/weather/forecast`: Get weather forecast data +- `GET /api/v1/weather/cities`: Get list of cities in the database +- `GET /api/v1/weather/history/{city_id}`: Get historical weather data for a city +- `GET /health`: Health check endpoint + +## Tech Stack + +- **FastAPI**: High-performance web framework for building APIs +- **SQLAlchemy**: SQL toolkit and ORM for database interactions +- **Pydantic**: Data validation and settings management +- **Alembic**: Database migration tool +- **httpx**: Asynchronous HTTP client for external API calls +- **SQLite**: Lightweight relational database +- **Tenacity**: Retry library for robust API calls + +## Getting Started + +### Prerequisites + +- Python 3.8+ +- pip package manager + +### Installation + +1. Clone the repository: + ``` + git clone + cd weatherdataapi + ``` + +2. Install dependencies: + ``` + pip install -r requirements.txt + ``` + +3. Run database migrations: + ``` + alembic upgrade head + ``` + +4. Start the server: + ``` + uvicorn main:app --reload + ``` + +5. Visit the API documentation: + ``` + http://localhost:8000/docs + ``` + +## Environment Variables + +The API can be configured using the following environment variables: + +- `OPENWEATHERMAP_API_KEY`: Your OpenWeatherMap API key (default is provided) +- `CACHE_EXPIRE_IN_SECONDS`: Duration for which to cache weather data (default: 1800 seconds) + +## Database Schema + +The API uses two main data models: + +- **City**: Stores information about cities (name, country, coordinates) +- **WeatherRecord**: Stores weather data points (temperature, humidity, etc.) + +## Example Requests + +### Get Current Weather by City Name + +``` +GET /api/v1/weather/current?city=London&country=GB +``` + +### Get Weather Forecast by Coordinates + +``` +GET /api/v1/weather/forecast?lat=51.5074&lon=0.1278 +``` + +### Get Historical Weather Data + +``` +GET /api/v1/weather/history/1?start_date=2023-01-01T00:00:00Z&end_date=2023-01-02T00:00:00Z +``` \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..b854d48 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,102 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:///app/storage/db/db.sqlite + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..b3252aa --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,84 @@ +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from app.models import weather +from app.database.session import Base +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + +from app.core.config import settings +config.set_main_option("sqlalchemy.url", settings.SQLALCHEMY_DATABASE_URL) + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1e4564e --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/alembic/versions/f9a8b7c6d5e4_initial_migration.py b/alembic/versions/f9a8b7c6d5e4_initial_migration.py new file mode 100644 index 0000000..7f23e79 --- /dev/null +++ b/alembic/versions/f9a8b7c6d5e4_initial_migration.py @@ -0,0 +1,66 @@ +"""Initial migration + +Revision ID: f9a8b7c6d5e4 +Revises: +Create Date: 2025-05-12 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f9a8b7c6d5e4' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # Create cities table + op.create_table( + 'cities', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('country', sa.String(), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_cities_id'), 'cities', ['id'], unique=False) + op.create_index(op.f('ix_cities_name'), 'cities', ['name'], unique=False) + + # Create weather_records table + op.create_table( + 'weather_records', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('city_id', sa.Integer(), nullable=True), + sa.Column('temperature', sa.Float(), nullable=True), + sa.Column('feels_like', sa.Float(), nullable=True), + sa.Column('humidity', sa.Integer(), nullable=True), + sa.Column('pressure', sa.Integer(), nullable=True), + sa.Column('wind_speed', sa.Float(), nullable=True), + sa.Column('wind_direction', sa.Integer(), nullable=True), + sa.Column('weather_condition', sa.String(), nullable=True), + sa.Column('weather_description', sa.String(), nullable=True), + sa.Column('clouds', sa.Integer(), nullable=True), + sa.Column('rain_1h', sa.Float(), nullable=True), + sa.Column('snow_1h', sa.Float(), nullable=True), + sa.Column('timestamp', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['city_id'], ['cities.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_weather_records_id'), 'weather_records', ['id'], unique=False) + op.create_index(op.f('ix_weather_records_timestamp'), 'weather_records', ['timestamp'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_weather_records_timestamp'), table_name='weather_records') + op.drop_index(op.f('ix_weather_records_id'), table_name='weather_records') + op.drop_table('weather_records') + op.drop_index(op.f('ix_cities_name'), table_name='cities') + op.drop_index(op.f('ix_cities_id'), table_name='cities') + op.drop_table('cities') \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/endpoints/__init__.py b/app/api/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/endpoints/weather.py b/app/api/endpoints/weather.py new file mode 100644 index 0000000..f09850e --- /dev/null +++ b/app/api/endpoints/weather.py @@ -0,0 +1,193 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import List, Optional +from datetime import datetime, timedelta + +from app.database.session import get_db +from app.schemas.weather import ( + City, CityCreate, WeatherRecord, WeatherRecordCreate, + CurrentWeatherResponse, ForecastResponse, ForecastItem, + WeatherSearchParams +) +from app.crud.weather import city as city_crud +from app.crud.weather import weather_record as weather_crud +from app.services.openweather import openweather_client + +router = APIRouter() + +@router.get("/current", response_model=CurrentWeatherResponse) +async def get_current_weather( + city: Optional[str] = Query(None, description="City name"), + country: Optional[str] = Query(None, description="Country code (ISO 3166)"), + lat: Optional[float] = Query(None, description="Latitude"), + lon: Optional[float] = Query(None, description="Longitude"), + db: Session = Depends(get_db) +): + """ + Get current weather data for a location by city name or coordinates. + """ + # Input validation + if (city is None and (lat is None or lon is None)): + raise HTTPException( + status_code=400, + detail="Either city or lat,lon parameters must be provided" + ) + + weather_data = None + db_city = None + + # Fetch weather data based on provided parameters + if city: + # Check if we have this city in our database + if country: + db_city = city_crud.get_by_name_and_country(db, name=city, country=country) + + # Fetch from OpenWeatherMap API + api_response = await openweather_client.get_current_weather_by_city(city, country) + city_data, weather = openweather_client.parse_weather_data(api_response) + + # Create or update city in database + if not db_city: + city_create = CityCreate(**city_data) + db_city = city_crud.create(db, obj_in=city_create) + + # Create weather record in database + weather.city_id = db_city.id + db_weather = weather_crud.create(db, obj_in=weather) + + else: # Using coordinates + # Check if we have this location in our database + db_city = city_crud.get_by_coordinates(db, latitude=lat, longitude=lon) + + # Fetch from OpenWeatherMap API + api_response = await openweather_client.get_current_weather_by_coordinates(lat, lon) + city_data, weather = openweather_client.parse_weather_data(api_response) + + # Create or update city in database + if not db_city: + city_create = CityCreate(**city_data) + db_city = city_crud.create(db, obj_in=city_create) + + # Create weather record in database + weather.city_id = db_city.id + db_weather = weather_crud.create(db, obj_in=weather) + + return CurrentWeatherResponse(city=db_city, weather=db_weather) + +@router.get("/forecast", response_model=ForecastResponse) +async def get_weather_forecast( + city: Optional[str] = Query(None, description="City name"), + country: Optional[str] = Query(None, description="Country code (ISO 3166)"), + lat: Optional[float] = Query(None, description="Latitude"), + lon: Optional[float] = Query(None, description="Longitude"), + db: Session = Depends(get_db) +): + """ + Get 5-day weather forecast for a location by city name or coordinates. + """ + # Input validation + if (city is None and (lat is None or lon is None)): + raise HTTPException( + status_code=400, + detail="Either city or lat,lon parameters must be provided" + ) + + db_city = None + forecast_items = [] + + # Fetch forecast data based on provided parameters + if city: + # Check if we have this city in our database + if country: + db_city = city_crud.get_by_name_and_country(db, name=city, country=country) + + # Fetch from OpenWeatherMap API + api_response = await openweather_client.get_forecast_by_city(city, country) + city_data, forecast_data = openweather_client.parse_forecast_data(api_response) + + # Create or update city in database + if not db_city: + city_create = CityCreate(**city_data) + db_city = city_crud.create(db, obj_in=city_create) + + # Process forecast items + for item in forecast_data: + item.city_id = db_city.id + db_item = weather_crud.create(db, obj_in=item) + forecast_items.append(ForecastItem(**item.dict())) + + else: # Using coordinates + # Check if we have this location in our database + db_city = city_crud.get_by_coordinates(db, latitude=lat, longitude=lon) + + # Fetch from OpenWeatherMap API + api_response = await openweather_client.get_forecast_by_coordinates(lat, lon) + city_data, forecast_data = openweather_client.parse_forecast_data(api_response) + + # Create or update city in database + if not db_city: + city_create = CityCreate(**city_data) + db_city = city_crud.create(db, obj_in=city_create) + + # Process forecast items + for item in forecast_data: + item.city_id = db_city.id + db_item = weather_crud.create(db, obj_in=item) + forecast_items.append(ForecastItem(**item.dict())) + + return ForecastResponse(city=db_city, forecast=forecast_items) + +@router.get("/cities", response_model=List[City]) +def get_cities( + name: Optional[str] = Query(None, description="Filter by city name"), + country: Optional[str] = Query(None, description="Filter by country code"), + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db) +): + """ + Retrieve cities from the database with optional filtering. + """ + if name and country: + city = city_crud.get_by_name_and_country(db, name=name, country=country) + return [city] if city else [] + + # Implement simple filtering logic - in a real app, we'd use more sophisticated query building + cities = db.query(city_crud.model) + + if name: + cities = cities.filter(city_crud.model.name.ilike(f"%{name}%")) + + if country: + cities = cities.filter(city_crud.model.country == country) + + return cities.offset(skip).limit(limit).all() + +@router.get("/history/{city_id}", response_model=List[WeatherRecord]) +def get_weather_history( + city_id: int, + start_date: Optional[datetime] = Query(None, description="Start date for historical data"), + end_date: Optional[datetime] = Query(None, description="End date for historical data"), + db: Session = Depends(get_db) +): + """ + Get historical weather data for a city. + """ + # Default to past 24 hours if no date range provided + if not start_date: + start_date = datetime.utcnow() - timedelta(days=1) + + if not end_date: + end_date = datetime.utcnow() + + # Check if city exists + db_city = city_crud.get(db, id=city_id) + if not db_city: + raise HTTPException(status_code=404, detail="City not found") + + # Get historical data + history = weather_crud.get_history_by_city_id( + db, city_id=city_id, start_date=start_date, end_date=end_date + ) + + return history \ No newline at end of file diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..bce9aa6 --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,5 @@ +from fastapi import APIRouter +from app.api.endpoints import weather + +api_router = APIRouter() +api_router.include_router(weather.router, prefix="/weather", tags=["weather"]) \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..645d8c2 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,33 @@ +import os +from pathlib import Path +from pydantic_settings import BaseSettings, SettingsConfigDict +from dotenv import load_dotenv + +# Load .env file if it exists +load_dotenv() + +class Settings(BaseSettings): + API_V1_STR: str = "/api/v1" + PROJECT_NAME: str = "Weather Data API" + + # OpenWeatherMap API + OPENWEATHERMAP_API_KEY: str = "bd5e378503939ddaee76f12ad7a97608" + OPENWEATHERMAP_API_URL: str = "https://api.openweathermap.org/data/2.5" + + # Cache settings + CACHE_EXPIRE_IN_SECONDS: int = 1800 # 30 minutes + + # Database settings + DB_DIR = Path("/app") / "storage" / "db" + SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=True, + ) + +settings = Settings() + +# Create DB directory if it doesn't exist +settings.DB_DIR.mkdir(parents=True, exist_ok=True) \ No newline at end of file diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..24ca32b --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,143 @@ +from fastapi import HTTPException, Request +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException +from typing import Union, Dict, Any +import logging + +# Configure logger +logger = logging.getLogger(__name__) + +class WeatherAPIException(HTTPException): + """Base exception class for the Weather API.""" + def __init__( + self, + status_code: int, + detail: Any = None, + headers: Dict[str, str] = None, + error_code: str = None + ): + self.error_code = error_code + super().__init__(status_code=status_code, detail=detail, headers=headers) + +class ExternalAPIError(WeatherAPIException): + """Exception raised when external API calls fail.""" + def __init__( + self, + detail: Any = "External API error", + status_code: int = 503, + headers: Dict[str, str] = None, + error_code: str = "external_api_error" + ): + super().__init__( + status_code=status_code, + detail=detail, + headers=headers, + error_code=error_code + ) + +class InvalidParameterError(WeatherAPIException): + """Exception raised when request parameters are invalid.""" + def __init__( + self, + detail: Any = "Invalid parameters", + status_code: int = 400, + headers: Dict[str, str] = None, + error_code: str = "invalid_parameter" + ): + super().__init__( + status_code=status_code, + detail=detail, + headers=headers, + error_code=error_code + ) + +class ResourceNotFoundError(WeatherAPIException): + """Exception raised when a requested resource is not found.""" + def __init__( + self, + detail: Any = "Resource not found", + status_code: int = 404, + headers: Dict[str, str] = None, + error_code: str = "resource_not_found" + ): + super().__init__( + status_code=status_code, + detail=detail, + headers=headers, + error_code=error_code + ) + +# Error handlers +async def http_exception_handler(request: Request, exc: Union[HTTPException, StarletteHTTPException]) -> JSONResponse: + """ + Handle HTTPExceptions and return a standardized JSON response. + """ + headers = getattr(exc, "headers", None) + + # Log the exception + logger.error(f"HTTP Exception: {exc.status_code} - {exc.detail}") + + # Add more context if it's our custom exception + if isinstance(exc, WeatherAPIException): + content = { + "error": { + "code": exc.error_code, + "message": exc.detail + }, + "status_code": exc.status_code + } + else: + content = { + "error": { + "code": "http_error", + "message": exc.detail + }, + "status_code": exc.status_code + } + + return JSONResponse( + status_code=exc.status_code, + content=content, + headers=headers + ) + +async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: + """ + Handle validation errors from Pydantic models and return a standardized error response. + """ + # Log the exception + logger.error(f"Validation error: {exc.errors()}") + + # Create a more user-friendly error detail + errors = [] + for error in exc.errors(): + loc = " -> ".join([str(loc_item) for loc_item in error["loc"]]) + errors.append({ + "location": loc, + "message": error["msg"], + "type": error["type"] + }) + + content = { + "error": { + "code": "validation_error", + "message": "Request validation error", + "details": errors + }, + "status_code": 422 + } + + return JSONResponse( + status_code=422, + content=content + ) + +# Configure application-wide exception handlers +def configure_exception_handlers(app): + """ + Configure exception handlers for the FastAPI application. + """ + app.add_exception_handler(HTTPException, http_exception_handler) + app.add_exception_handler(StarletteHTTPException, http_exception_handler) + app.add_exception_handler(RequestValidationError, validation_exception_handler) \ No newline at end of file diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud/base.py b/app/crud/base.py new file mode 100644 index 0000000..92e3dcb --- /dev/null +++ b/app/crud/base.py @@ -0,0 +1,64 @@ +from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel +from sqlalchemy.orm import Session +from sqlalchemy import desc + +from app.database.session import Base + +ModelType = TypeVar("ModelType", bound=Base) +CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) + +class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): + def __init__(self, model: Type[ModelType]): + """ + CRUD object with default methods to Create, Read, Update, Delete (CRUD). + + **Parameters** + * `model`: A SQLAlchemy model class + * `schema`: A Pydantic model (schema) class + """ + self.model = model + + def get(self, db: Session, id: Any) -> Optional[ModelType]: + return db.query(self.model).filter(self.model.id == id).first() + + def get_multi( + self, db: Session, *, skip: int = 0, limit: int = 100 + ) -> List[ModelType]: + return db.query(self.model).offset(skip).limit(limit).all() + + def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType: + obj_in_data = jsonable_encoder(obj_in) + db_obj = self.model(**obj_in_data) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update( + self, + db: Session, + *, + db_obj: ModelType, + obj_in: Union[UpdateSchemaType, Dict[str, Any]] + ) -> ModelType: + obj_data = jsonable_encoder(db_obj) + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) + for field in obj_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def remove(self, db: Session, *, id: int) -> ModelType: + obj = db.query(self.model).get(id) + db.delete(obj) + db.commit() + return obj \ No newline at end of file diff --git a/app/crud/weather.py b/app/crud/weather.py new file mode 100644 index 0000000..9f757fa --- /dev/null +++ b/app/crud/weather.py @@ -0,0 +1,54 @@ +from typing import List, Optional, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy import func +from datetime import datetime, timedelta + +from app.crud.base import CRUDBase +from app.models.weather import City, WeatherRecord +from app.schemas.weather import CityCreate, CityUpdate, WeatherRecordCreate + +class CRUDCity(CRUDBase[City, CityCreate, CityUpdate]): + def get_by_name_and_country(self, db: Session, *, name: str, country: str) -> Optional[City]: + return db.query(City).filter( + func.lower(City.name) == func.lower(name), + func.lower(City.country) == func.lower(country) + ).first() + + def get_by_coordinates(self, db: Session, *, latitude: float, longitude: float) -> Optional[City]: + # Use a small delta to find cities by approximate coordinates + delta = 0.01 # About 1km at the equator + return db.query(City).filter( + City.latitude.between(latitude - delta, latitude + delta), + City.longitude.between(longitude - delta, longitude + delta) + ).first() + +class CRUDWeatherRecord(CRUDBase[WeatherRecord, WeatherRecordCreate, WeatherRecordCreate]): + def get_latest_by_city_id(self, db: Session, *, city_id: int) -> Optional[WeatherRecord]: + return db.query(WeatherRecord).filter( + WeatherRecord.city_id == city_id + ).order_by(WeatherRecord.timestamp.desc()).first() + + def get_history_by_city_id( + self, db: Session, *, city_id: int, start_date: datetime, end_date: datetime + ) -> List[WeatherRecord]: + return db.query(WeatherRecord).filter( + WeatherRecord.city_id == city_id, + WeatherRecord.timestamp.between(start_date, end_date) + ).order_by(WeatherRecord.timestamp).all() + + def get_forecast_by_city_id( + self, db: Session, *, city_id: int + ) -> List[WeatherRecord]: + """ + Get weather forecast data for a city. + In a real application, this might fetch forecasts from an external API, + but for this example, we'll just return the most recent data. + """ + current_time = datetime.utcnow() + return db.query(WeatherRecord).filter( + WeatherRecord.city_id == city_id, + WeatherRecord.timestamp >= current_time + ).order_by(WeatherRecord.timestamp).all() + +city = CRUDCity(City) +weather_record = CRUDWeatherRecord(WeatherRecord) \ No newline at end of file diff --git a/app/database/__init__.py b/app/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database/session.py b/app/database/session.py new file mode 100644 index 0000000..3089e14 --- /dev/null +++ b/app/database/session.py @@ -0,0 +1,22 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from app.core.config import settings + +engine = create_engine( + settings.SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +# Dependency to get DB session +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/weather.py b/app/models/weather.py new file mode 100644 index 0000000..6a9f201 --- /dev/null +++ b/app/models/weather.py @@ -0,0 +1,45 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from datetime import datetime + +from app.database.session import Base + +class City(Base): + __tablename__ = "cities" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True) + country = Column(String) + latitude = Column(Float) + longitude = Column(Float) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + weather_records = relationship("WeatherRecord", back_populates="city") + + def __repr__(self): + return f"" + +class WeatherRecord(Base): + __tablename__ = "weather_records" + + id = Column(Integer, primary_key=True, index=True) + city_id = Column(Integer, ForeignKey("cities.id")) + temperature = Column(Float) + feels_like = Column(Float) + humidity = Column(Integer) + pressure = Column(Integer) + wind_speed = Column(Float) + wind_direction = Column(Integer) + weather_condition = Column(String) + weather_description = Column(String) + clouds = Column(Integer) + rain_1h = Column(Float, nullable=True) + snow_1h = Column(Float, nullable=True) + timestamp = Column(DateTime, index=True) + created_at = Column(DateTime, default=datetime.utcnow) + + city = relationship("City", back_populates="weather_records") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/weather.py b/app/schemas/weather.py new file mode 100644 index 0000000..08ff17d --- /dev/null +++ b/app/schemas/weather.py @@ -0,0 +1,78 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional, List + +# City schemas +class CityBase(BaseModel): + name: str + country: str + latitude: float + longitude: float + +class CityCreate(CityBase): + pass + +class CityUpdate(CityBase): + name: Optional[str] = None + country: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + +class CityInDB(CityBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class City(CityInDB): + pass + +# Weather record schemas +class WeatherRecordBase(BaseModel): + temperature: float = Field(..., description="Temperature in Kelvin") + feels_like: float = Field(..., description="Feels like temperature in Kelvin") + humidity: int = Field(..., description="Humidity in percentage") + pressure: int = Field(..., description="Atmospheric pressure in hPa") + wind_speed: float = Field(..., description="Wind speed in meter/sec") + wind_direction: int = Field(..., description="Wind direction in degrees") + weather_condition: str = Field(..., description="Main weather condition") + weather_description: str = Field(..., description="Weather condition description") + clouds: int = Field(..., description="Cloudiness in percentage") + rain_1h: Optional[float] = Field(None, description="Rain volume for last hour in mm") + snow_1h: Optional[float] = Field(None, description="Snow volume for last hour in mm") + timestamp: datetime = Field(..., description="Timestamp of the weather data") + +class WeatherRecordCreate(WeatherRecordBase): + city_id: int + +class WeatherRecordInDB(WeatherRecordBase): + id: int + city_id: int + created_at: datetime + + class Config: + from_attributes = True + +class WeatherRecord(WeatherRecordInDB): + pass + +# Weather response schemas for API +class CurrentWeatherResponse(BaseModel): + city: City + weather: WeatherRecord + +class ForecastItem(WeatherRecordBase): + pass + +class ForecastResponse(BaseModel): + city: City + forecast: List[ForecastItem] + +# Search schemas +class WeatherSearchParams(BaseModel): + city: Optional[str] = None + country: Optional[str] = None + lat: Optional[float] = None + lon: Optional[float] = None \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/cache.py b/app/services/cache.py new file mode 100644 index 0000000..76d832a --- /dev/null +++ b/app/services/cache.py @@ -0,0 +1,99 @@ +from typing import Any, Dict, Optional, Callable, TypeVar, Awaitable +from datetime import datetime, timedelta +import hashlib +import json + +# In-memory cache +cache: Dict[str, Dict[str, Any]] = {} + +T = TypeVar("T") + +class Cache: + @staticmethod + def _generate_key(prefix: str, *args: Any, **kwargs: Any) -> str: + """ + Generate a unique cache key based on the function arguments. + """ + key_parts = [prefix] + + # Add positional args to key + for arg in args: + key_parts.append(str(arg)) + + # Add keyword args to key (sorted by key to ensure consistency) + for k, v in sorted(kwargs.items()): + key_parts.append(f"{k}={v}") + + # Join all parts and hash + key_data = ":".join(key_parts) + return hashlib.md5(key_data.encode()).hexdigest() + + @staticmethod + def set(key: str, value: Any, expire_seconds: int = 1800) -> None: + """ + Store a value in the cache with an expiration time. + """ + expiry = datetime.utcnow() + timedelta(seconds=expire_seconds) + cache[key] = { + "value": value, + "expiry": expiry + } + + @staticmethod + def get(key: str) -> Optional[Any]: + """ + Retrieve a value from the cache if it exists and hasn't expired. + """ + if key not in cache: + return None + + cache_item = cache[key] + if cache_item["expiry"] < datetime.utcnow(): + # Remove expired item + del cache[key] + return None + + return cache_item["value"] + + @staticmethod + def invalidate(key: str) -> None: + """ + Remove a specific item from the cache. + """ + if key in cache: + del cache[key] + + @staticmethod + def clear_all() -> None: + """ + Clear the entire cache. + """ + cache.clear() + +def cached(prefix: str, expire_seconds: int = 1800): + """ + Decorator for async functions to cache their results. + + Args: + prefix: Prefix for the cache key to avoid collisions + expire_seconds: Time in seconds until the cached result expires + """ + def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]: + async def wrapper(*args: Any, **kwargs: Any) -> T: + # Generate a unique key based on function arguments + key = Cache._generate_key(prefix, *args, **kwargs) + + # Check if we have a cached result + cached_result = Cache.get(key) + if cached_result is not None: + return cached_result + + # Call the original function + result = await func(*args, **kwargs) + + # Cache the result + Cache.set(key, result, expire_seconds) + + return result + return wrapper + return decorator \ No newline at end of file diff --git a/app/services/openweather.py b/app/services/openweather.py new file mode 100644 index 0000000..1985de4 --- /dev/null +++ b/app/services/openweather.py @@ -0,0 +1,177 @@ +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() \ No newline at end of file diff --git a/app/tests/__init__.py b/app/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..82efc63 --- /dev/null +++ b/main.py @@ -0,0 +1,30 @@ +from fastapi import FastAPI +from app.api.routes import api_router +from app.core.config import settings +from app.core.exceptions import configure_exception_handlers +from app.database.session import Base, engine + +# Create database tables +Base.metadata.create_all(bind=engine) + +app = FastAPI( + title=settings.PROJECT_NAME, + description="Weather Data API for OpenWeatherMap", + version="0.1.0", + openapi_url=f"{settings.API_V1_STR}/openapi.json", + docs_url="/docs", + redoc_url="/redoc", +) + +# Configure exception handlers +configure_exception_handlers(app) + +# Include API routes +app.include_router(api_router, prefix=settings.API_V1_STR) + +@app.get("/health", tags=["health"]) +async def health_check(): + """ + Health check endpoint to verify the API is running. + """ + return {"status": "ok", "message": "Weather Data API is operational"} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d3fe279 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.104.0 +uvicorn==0.23.2 +sqlalchemy==2.0.22 +alembic==1.12.0 +pydantic==2.4.2 +httpx==0.24.1 +python-dotenv==1.0.0 +python-multipart==0.0.6 +email-validator==2.0.0 +pytest==7.4.2 +pathlib==1.0.1 +tenacity==8.2.3 \ No newline at end of file