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)
This commit is contained in:
Automated Action 2025-05-12 14:26:44 +00:00
parent 8a00204c0b
commit 1468af1391
28 changed files with 1331 additions and 2 deletions

102
README.md
View File

@ -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 <repository-url>
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
```

102
alembic.ini Normal file
View File

@ -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

84
alembic/env.py Normal file
View File

@ -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()

24
alembic/script.py.mako Normal file
View File

@ -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"}

View File

@ -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')

0
app/__init__.py Normal file
View File

0
app/api/__init__.py Normal file
View File

View File

View File

@ -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

5
app/api/routes.py Normal file
View File

@ -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"])

0
app/core/__init__.py Normal file
View File

33
app/core/config.py Normal file
View File

@ -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)

143
app/core/exceptions.py Normal file
View File

@ -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)

0
app/crud/__init__.py Normal file
View File

64
app/crud/base.py Normal file
View File

@ -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

54
app/crud/weather.py Normal file
View File

@ -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)

0
app/database/__init__.py Normal file
View File

22
app/database/session.py Normal file
View File

@ -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()

0
app/models/__init__.py Normal file
View File

45
app/models/weather.py Normal file
View File

@ -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"<City {self.name}, {self.country}>"
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"<WeatherRecord for {self.city.name} at {self.timestamp}>"

0
app/schemas/__init__.py Normal file
View File

78
app/schemas/weather.py Normal file
View File

@ -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

0
app/services/__init__.py Normal file
View File

99
app/services/cache.py Normal file
View File

@ -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

177
app/services/openweather.py Normal file
View File

@ -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()

0
app/tests/__init__.py Normal file
View File

30
main.py Normal file
View File

@ -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"}

12
requirements.txt Normal file
View File

@ -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