from contextlib import asynccontextmanager from collections.abc import AsyncGenerator from datetime import datetime, timedelta, timezone from fastapi import FastAPI, HTTPException, Query from app.database import ( close_pool, get_history_by_station_paginated, get_latest_by_station, get_station_exists, init_db, insert_weather_data, list_all_stations, ) from app.models import ( ObservationListResponse, StationInfo, StationListResponse, WeatherDataIn, WeatherRecordOut, ) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Initialize the DB pool and table on startup, tear down on shutdown.""" await init_db() yield await close_pool() app = FastAPI( title="Weather Data API", description="Receive and query weather station observations", version="0.2.0", lifespan=lifespan, ) # --------------------------------------------------------------------------- # Stations # --------------------------------------------------------------------------- @app.get("/stations", response_model=StationListResponse) async def list_stations() -> StationListResponse: """List all known weather stations derived from observation data.""" stations = await list_all_stations() return StationListResponse( stations=[StationInfo(**s) for s in stations], count=len(stations), ) # --------------------------------------------------------------------------- # Observations # --------------------------------------------------------------------------- @app.get( "/stations/{station_name}/observations/latest", response_model=WeatherRecordOut, ) async def get_latest_observation(station_name: str) -> WeatherRecordOut: """Return the most recent observation for a station. Returns 404 if the station has no observations. """ record = await get_latest_by_station(station_name) if record is None: raise HTTPException( status_code=404, detail=f"No observations found for station '{station_name}'", ) return WeatherRecordOut(**record) @app.get( "/stations/{station_name}/observations", response_model=ObservationListResponse, ) async def list_observations( station_name: str, hours: int | None = Query( None, gt=0, description="Look back this many hours from now (e.g. 24 = last 24 hours). " "Overrides start if both are provided.", ), start: datetime | None = Query( None, description="Start of the time range (ISO 8601, UTC, inclusive). " "Defaults to unbounded.", ), end: datetime | None = Query( None, description="End of the time range (ISO 8601, UTC, inclusive). " "Defaults to now.", ), limit: int | None = Query( None, gt=0, description="Maximum number of records to return.", ), offset: int = Query( 0, ge=0, description="Number of records to skip (for pagination).", ), ) -> ObservationListResponse: """List observations for a station with optional time-range filtering and pagination. - Use ``hours`` alone to get the last *N* hours of data. - Use ``start`` and/or ``end`` for a custom range. - Use ``limit`` and ``offset`` for pagination. - Returns 404 if the station has no observations at all. - Returns 200 with an empty ``records`` list when no data matches the filters. """ if not await get_station_exists(station_name): raise HTTPException( status_code=404, detail=f"Station '{station_name}' not found", ) now = datetime.now(timezone.utc) resolved_start: datetime | None = start resolved_end: datetime | None = end if hours is not None: resolved_start = now - timedelta(hours=hours) if end is None: resolved_end = now if resolved_start is not None and resolved_end is None: resolved_end = now records = await get_history_by_station_paginated( station_name, resolved_start, resolved_end, limit=limit, offset=offset, ) return ObservationListResponse( station_name=station_name, count=len(records), records=[WeatherRecordOut(**r) for r in records], ) @app.post( "/stations/{station_name}/observations", response_model=WeatherRecordOut, status_code=201, ) async def create_observation( station_name: str, payload: WeatherDataIn, ) -> WeatherRecordOut: """Ingest a weather observation for a station. The station is identified by the URL path. Stations are created implicitly when their first observation is posted. """ record = await insert_weather_data( station_name=station_name, temperature=payload.temperature, pressure=payload.pressure, relative_humidity=payload.relative_humidity, ) return WeatherRecordOut(**record)