Files
weather-data-api/app/main.py
T
2026-06-19 13:38:17 +08:00

102 lines
3.3 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,
get_latest_by_station,
init_db,
insert_weather_data,
)
from app.models import WeatherDataIn, WeatherHistoryResponse, 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.1.0",
lifespan=lifespan,
)
@app.post("/data", response_model=WeatherRecordOut, status_code=201)
async def post_weather_data(payload: WeatherDataIn) -> WeatherRecordOut:
"""Ingest a weather observation from a station.
The server records the time it was received (`received_at`).
"""
record = await insert_weather_data(
station_name=payload.station_name,
temperature=payload.temperature,
pressure=payload.pressure,
relative_humidity=payload.relative_humidity,
)
return WeatherRecordOut(**record)
@app.get("/data/{station_name}", response_model=WeatherRecordOut)
async def get_latest_weather(station_name: str) -> WeatherRecordOut:
"""Return the most recent observation for the given station."""
record = await get_latest_by_station(station_name)
if record is None:
raise HTTPException(
status_code=404,
detail=f"No data found for station '{station_name}'",
)
return WeatherRecordOut(**record)
@app.get("/data/{station_name}/history", response_model=WeatherHistoryResponse)
async def get_weather_history(
station_name: str,
hours: int | None = Query(
None,
gt=0,
description="Look back this many hours from now (e.g. 24 = last 24 hours). "
"Ignored if both start and end are provided.",
),
start: datetime | None = Query(
None,
description="Start of the time range (ISO 8601, UTC). Defaults to unbounded.",
),
end: datetime | None = Query(
None,
description="End of the time range (ISO 8601, UTC). Defaults to now.",
),
) -> WeatherHistoryResponse:
"""Return all observations for a station within a time range.
- Use ``hours`` alone to get the last *N* hours of data.
- Use ``start`` and/or ``end`` for a custom range.
- Returns an empty ``records`` list when no data matches.
"""
now = datetime.now(timezone.utc)
# Resolve start / end from the query parameters
resolved_start: datetime | None = start
resolved_end: datetime | None = end
if hours is not None:
# hours takes precedence for start, but explicit end still wins
resolved_start = now - timedelta(hours=hours)
if end is None:
resolved_end = now
# Default end to now if neither hours nor end was given and start was specified
if resolved_start is not None and resolved_end is None:
resolved_end = now
records = await get_history_by_station(station_name, resolved_start, resolved_end)
return WeatherHistoryResponse(station_name=station_name, records=records)