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:
parent
8a00204c0b
commit
1468af1391
102
README.md
102
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 <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
102
alembic.ini
Normal 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
84
alembic/env.py
Normal 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
24
alembic/script.py.mako
Normal 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"}
|
66
alembic/versions/f9a8b7c6d5e4_initial_migration.py
Normal file
66
alembic/versions/f9a8b7c6d5e4_initial_migration.py
Normal 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
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/endpoints/__init__.py
Normal file
0
app/api/endpoints/__init__.py
Normal file
193
app/api/endpoints/weather.py
Normal file
193
app/api/endpoints/weather.py
Normal 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
5
app/api/routes.py
Normal 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
0
app/core/__init__.py
Normal file
33
app/core/config.py
Normal file
33
app/core/config.py
Normal 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
143
app/core/exceptions.py
Normal 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
0
app/crud/__init__.py
Normal file
64
app/crud/base.py
Normal file
64
app/crud/base.py
Normal 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
54
app/crud/weather.py
Normal 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
0
app/database/__init__.py
Normal file
22
app/database/session.py
Normal file
22
app/database/session.py
Normal 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
0
app/models/__init__.py
Normal file
45
app/models/weather.py
Normal file
45
app/models/weather.py
Normal 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
0
app/schemas/__init__.py
Normal file
78
app/schemas/weather.py
Normal file
78
app/schemas/weather.py
Normal 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
0
app/services/__init__.py
Normal file
99
app/services/cache.py
Normal file
99
app/services/cache.py
Normal 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
177
app/services/openweather.py
Normal 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
0
app/tests/__init__.py
Normal file
30
main.py
Normal file
30
main.py
Normal 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
12
requirements.txt
Normal 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
|
Loading…
x
Reference in New Issue
Block a user