diff --git a/README.md b/README.md index e8acfba..43557f6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,93 @@ -# FastAPI Application +# Weather Data API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A FastAPI application that fetches and stores weather data from OpenWeatherMap. + +## Features + +- Retrieve current weather data by city name or coordinates +- Store weather data in a SQLite database +- Query historical weather data for specific locations +- RESTful API with automatic OpenAPI documentation +- Health check endpoint + +## Requirements + +- Python 3.8+ +- OpenWeatherMap API key + +## Installation + +1. Clone this repository: +``` +git clone +cd weatherdataapi +``` + +2. Create and activate a virtual environment: +``` +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +3. Install the dependencies: +``` +pip install -r requirements.txt +``` + +4. Set up environment variables: +``` +export OPENWEATHERMAP_API_KEY=your_api_key_here +``` + +Or create a `.env` file in the root directory: +``` +OPENWEATHERMAP_API_KEY=your_api_key_here +``` + +5. Run the database migrations: +``` +alembic upgrade head +``` + +## Usage + +Start the application: +``` +uvicorn main:app --reload +``` + +The API will be available at http://localhost:8000 + +### API Endpoints + +- `GET /api/v1/weather/current` - Get current weather (query parameters: city, country, lat, lon) +- `GET /api/v1/weather/history/{location_id}` - Get weather history for a location +- `GET /health` - Health check endpoint + +### API Documentation + +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## Project Structure + +``` +├── alembic/ # Database migration files +├── app/ # Application code +│ ├── api/ # API endpoints +│ │ ├── endpoints/ # Endpoint implementations +│ │ └── routes.py # API router +│ ├── core/ # Core application code +│ │ ├── config.py # Configuration settings +│ │ └── database.py # Database setup +│ ├── models/ # SQLAlchemy models +│ ├── schemas/ # Pydantic schemas +│ └── services/ # Business logic +├── main.py # Application entry point +├── requirements.txt # Python dependencies +└── README.md # Project documentation +``` + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..582146a --- /dev/null +++ b/alembic.ini @@ -0,0 +1,84 @@ +# 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 + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# 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 +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[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 + +# 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..11d36ba --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,81 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +from app.core.config import settings +from app.models import Base + +# 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 +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. + +# Override the sqlalchemy.url in alembic.ini with our own +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/initial_migration.py b/alembic/versions/initial_migration.py new file mode 100644 index 0000000..11e2e88 --- /dev/null +++ b/alembic/versions/initial_migration.py @@ -0,0 +1,62 @@ +"""initial migration + +Revision ID: initial_migration +Revises: +Create Date: 2025-05-12 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'initial_migration' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # Create locations table + op.create_table('locations', + 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_locations_id'), 'locations', ['id'], unique=False) + op.create_index(op.f('ix_locations_name'), 'locations', ['name'], unique=False) + + # Create weather_data table + op.create_table('weather_data', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('location_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('timestamp', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['location_id'], ['locations.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_weather_data_id'), 'weather_data', ['id'], unique=False) + op.create_index(op.f('ix_weather_data_timestamp'), 'weather_data', ['timestamp'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_weather_data_timestamp'), table_name='weather_data') + op.drop_index(op.f('ix_weather_data_id'), table_name='weather_data') + op.drop_table('weather_data') + op.drop_index(op.f('ix_locations_name'), table_name='locations') + op.drop_index(op.f('ix_locations_id'), table_name='locations') + op.drop_table('locations') \ 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..3cefb19 --- /dev/null +++ b/app/api/endpoints/weather.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Depends, Query, HTTPException +from typing import Optional +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.services.weather import WeatherService, get_weather_service +from app.schemas.weather import WeatherRequest, WeatherResponse, WeatherHistoryResponse + +router = APIRouter() + + +@router.get("/current", response_model=WeatherResponse) +async def get_current_weather( + request: WeatherRequest = Depends(), + weather_service: WeatherService = Depends(get_weather_service) +): + """ + Get current weather for a location. + + You can provide either city name or coordinates. + + - **city**: City name (optional) + - **country**: Country code (optional, used with city) + - **lat**: Latitude (optional) + - **lon**: Longitude (optional) + """ + if request.city: + location, weather = await weather_service.get_weather_by_city(request.city, request.country) + elif request.lat is not None and request.lon is not None: + location, weather = await weather_service.get_weather_by_coordinates(request.lat, request.lon) + else: + raise HTTPException( + status_code=400, + detail="Either city or lat/lon coordinates must be provided" + ) + + return { + "location": location, + "current_weather": weather + } + + +@router.get("/history/{location_id}", response_model=WeatherHistoryResponse) +def get_weather_history( + location_id: int, + limit: int = Query(10, ge=1, le=100), + weather_service: WeatherService = Depends(get_weather_service) +): + """ + Get weather history for a location. + + - **location_id**: ID of the location + - **limit**: Number of records to return (default: 10, max: 100) + """ + location, history = weather_service.get_weather_history(location_id, limit) + + return { + "location": location, + "history": 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..77254d1 --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,6 @@ +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..e0ee183 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,22 @@ +import os +from pathlib import Path +from typing import Optional +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + API_V1_STR: str = "/api/v1" + PROJECT_NAME: str = "Weather Data API" + + # Database configuration + DB_DIR = Path("/app") / "storage" / "db" + DB_DIR.mkdir(parents=True, exist_ok=True) + SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite" + + # OpenWeatherMap API configuration + OPENWEATHERMAP_API_KEY: Optional[str] = os.getenv("OPENWEATHERMAP_API_KEY") + OPENWEATHERMAP_API_URL: str = "https://api.openweathermap.org/data/2.5" + + class Config: + case_sensitive = True + +settings = Settings() \ No newline at end of file diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..34ad308 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,21 @@ +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() + +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..5f41a57 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +from app.models.weather import Location, WeatherData \ No newline at end of file diff --git a/app/models/weather.py b/app/models/weather.py new file mode 100644 index 0000000..7353318 --- /dev/null +++ b/app/models/weather.py @@ -0,0 +1,38 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from datetime import datetime + +from app.core.database import Base + +class Location(Base): + __tablename__ = "locations" + + 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_data = relationship("WeatherData", back_populates="location") + + +class WeatherData(Base): + __tablename__ = "weather_data" + + id = Column(Integer, primary_key=True, index=True) + location_id = Column(Integer, ForeignKey("locations.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) + timestamp = Column(DateTime, index=True) + created_at = Column(DateTime, default=datetime.utcnow) + + location = relationship("Location", back_populates="weather_data") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..81ba923 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,5 @@ +from app.schemas.weather import ( + Location, LocationCreate, LocationUpdate, + WeatherData, WeatherDataCreate, + WeatherRequest, WeatherResponse, WeatherHistoryResponse +) \ No newline at end of file diff --git a/app/schemas/weather.py b/app/schemas/weather.py new file mode 100644 index 0000000..46f448a --- /dev/null +++ b/app/schemas/weather.py @@ -0,0 +1,81 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional, List + + +class LocationBase(BaseModel): + name: str + country: str + latitude: float + longitude: float + + +class LocationCreate(LocationBase): + pass + + +class LocationUpdate(LocationBase): + name: Optional[str] = None + country: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + + +class LocationInDBBase(LocationBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + +class Location(LocationInDBBase): + pass + + +class WeatherDataBase(BaseModel): + temperature: float + feels_like: float + humidity: int + pressure: int + wind_speed: float + wind_direction: int + weather_condition: str + weather_description: str + clouds: int + timestamp: datetime + + +class WeatherDataCreate(WeatherDataBase): + location_id: int + + +class WeatherDataInDBBase(WeatherDataBase): + id: int + location_id: int + created_at: datetime + + class Config: + orm_mode = True + + +class WeatherData(WeatherDataInDBBase): + pass + + +class WeatherResponse(BaseModel): + location: Location + current_weather: WeatherData + + +class WeatherHistoryResponse(BaseModel): + location: Location + history: List[WeatherData] + + +class WeatherRequest(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..ac56bf0 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +from app.services.weather import WeatherService, get_weather_service \ No newline at end of file diff --git a/app/services/weather.py b/app/services/weather.py new file mode 100644 index 0000000..5e169b9 --- /dev/null +++ b/app/services/weather.py @@ -0,0 +1,136 @@ +import httpx +from fastapi import HTTPException, Depends +from sqlalchemy.orm import Session +from typing import Optional, Dict, Any, Tuple +from datetime import datetime + +from app.core.config import settings +from app.core.database import get_db +from app.models.weather import Location, WeatherData +from app.schemas.weather import LocationCreate, WeatherDataCreate + + +class WeatherService: + def __init__(self, db: Session): + self.db = db + self.api_key = settings.OPENWEATHERMAP_API_KEY + self.api_url = settings.OPENWEATHERMAP_API_URL + + if not self.api_key: + raise ValueError("OpenWeatherMap API key is not set") + + async def get_weather_by_city(self, city: str, country: Optional[str] = None) -> Tuple[Location, WeatherData]: + """ + Get current weather data for a city and save it to the database + """ + query = f"q={city}" + if country: + query += f",{country}" + + return await self._fetch_and_save_weather(query) + + async def get_weather_by_coordinates(self, lat: float, lon: float) -> Tuple[Location, WeatherData]: + """ + Get current weather data for coordinates and save it to the database + """ + query = f"lat={lat}&lon={lon}" + return await self._fetch_and_save_weather(query) + + async def _fetch_and_save_weather(self, query: str) -> Tuple[Location, WeatherData]: + """ + Fetch weather data from OpenWeatherMap API and save it to the database + """ + url = f"{self.api_url}/weather?{query}&appid={self.api_key}&units=metric" + + async with httpx.AsyncClient() as client: + response = await client.get(url) + + if response.status_code != 200: + error = response.json().get("message", "Unknown error") + raise HTTPException(status_code=response.status_code, detail=f"OpenWeatherMap API error: {error}") + + data = response.json() + + # Get or create location + location = self._get_or_create_location(data) + + # Create weather data + weather_data = self._create_weather_data(data, location.id) + + return location, weather_data + + def _get_or_create_location(self, data: Dict[Any, Any]) -> Location: + """ + Get location from database or create it if it doesn't exist + """ + # Check if location already exists + existing_location = self.db.query(Location).filter( + Location.latitude == data["coord"]["lat"], + Location.longitude == data["coord"]["lon"] + ).first() + + if existing_location: + return existing_location + + # Create new location + location_data = LocationCreate( + name=data["name"], + country=data["sys"]["country"], + latitude=data["coord"]["lat"], + longitude=data["coord"]["lon"] + ) + + location = Location(**location_data.dict()) + self.db.add(location) + self.db.commit() + self.db.refresh(location) + + return location + + def _create_weather_data(self, data: Dict[Any, Any], location_id: int) -> WeatherData: + """ + Create weather data record in the database + """ + weather = data["weather"][0] + main = data["main"] + wind = data["wind"] + clouds = data["clouds"] + + weather_data_create = WeatherDataCreate( + location_id=location_id, + temperature=main["temp"], + feels_like=main["feels_like"], + humidity=main["humidity"], + pressure=main["pressure"], + wind_speed=wind["speed"], + wind_direction=wind.get("deg", 0), + weather_condition=weather["main"], + weather_description=weather["description"], + clouds=clouds["all"], + timestamp=datetime.fromtimestamp(data["dt"]) + ) + + weather_data = WeatherData(**weather_data_create.dict()) + self.db.add(weather_data) + self.db.commit() + self.db.refresh(weather_data) + + return weather_data + + def get_weather_history(self, location_id: int, limit: int = 10): + """ + Get weather history for a location + """ + location = self.db.query(Location).filter(Location.id == location_id).first() + if not location: + raise HTTPException(status_code=404, detail="Location not found") + + history = self.db.query(WeatherData).filter( + WeatherData.location_id == location_id + ).order_by(WeatherData.timestamp.desc()).limit(limit).all() + + return location, history + + +def get_weather_service(db: Session = Depends(get_db)) -> WeatherService: + return WeatherService(db) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..80c8764 --- /dev/null +++ b/main.py @@ -0,0 +1,25 @@ +from fastapi import FastAPI +from app.api.routes import api_router +from app.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + description="API for retrieving weather data from OpenWeatherMap", + version="0.1.0", + openapi_url=f"{settings.API_V1_STR}/openapi.json", + docs_url="/docs", + redoc_url="/redoc", +) + +app.include_router(api_router, prefix=settings.API_V1_STR) + +@app.get("/health", status_code=200) +def health_check(): + """ + Health check endpoint + """ + return {"status": "healthy"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..54d14a0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.110.1 +uvicorn==0.25.0 +sqlalchemy==2.0.28 +alembic==1.13.0 +httpx==0.26.0 +pydantic==2.6.3 +pydantic-settings==2.1.0 +python-dotenv==1.0.1 \ No newline at end of file