174 lines
4.9 KiB
Python
174 lines
4.9 KiB
Python
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)
|