Files
HexaWetter/api/app/main.py
2026-06-18 23:59:08 +02:00

301 lines
9.6 KiB
Python

from __future__ import annotations
import logging
import time
from collections import defaultdict
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any
import httpx
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from app.cache import close_redis, init_redis
from app.config import (
APP_NAME,
APP_VERSION,
DEFAULT_LAT,
DEFAULT_LON,
DEFAULT_PLACE,
DWD_WMS_URL,
RADAR_PRODUCT,
RATE_LIMIT_PER_MINUTE,
RAW_RADAR_DIR,
RENDERED_RADAR_DIR,
)
from app.database import close_db, init_db
from app.http_client import close_http_client, init_http_client
from app.services.dashboard import build_dashboard
from app.services.dwd_radar import fetch_radar_index
from app.services.geocoding import reverse_geocode, search_places
from app.routes.admin import router as admin_router
from app.security import (
cors_allowed_origins,
is_api_key_exempt,
trusted_hosts,
validate_security_config,
verify_request_api_key,
)
from app.services.warnings import fetch_warnings_for_location
from app.services.weather import get_forecast, get_observations
from app.services.wms import fetch_wms_time_steps
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logger = logging.getLogger(__name__)
_rate_buckets: dict[str, list[float]] = defaultdict(list)
class Location(BaseModel):
lat: float = DEFAULT_LAT
lon: float = DEFAULT_LON
place: str = DEFAULT_PLACE
class FavoriteLocation(BaseModel):
place: str
lat: float = Field(ge=-90, le=90)
lon: float = Field(ge=-180, le=180)
@asynccontextmanager
async def lifespan(_: FastAPI):
validate_security_config()
await init_http_client()
await init_db()
await init_redis()
RENDERED_RADAR_DIR.mkdir(parents=True, exist_ok=True)
RAW_RADAR_DIR.mkdir(parents=True, exist_ok=True)
yield
await close_redis()
await close_db()
await close_http_client()
app = FastAPI(title=APP_NAME, version=APP_VERSION, lifespan=lifespan)
app.include_router(admin_router)
_hosts = list(trusted_hosts())
if _hosts:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=_hosts)
app.add_middleware(
CORSMiddleware,
allow_origins=cors_allowed_origins(),
allow_methods=["GET", "POST", "PUT", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-API-Key"],
)
@app.middleware("http")
async def api_key_middleware(request: Request, call_next):
path = request.url.path
if path.startswith("/api/") and not is_api_key_exempt(path):
if not verify_request_api_key(request):
return JSONResponse(
status_code=401,
content={"detail": "Invalid or missing API key"},
headers={"WWW-Authenticate": "ApiKey"},
)
return await call_next(request)
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "geolocation=(self)"
if path := request.url.path:
if path.startswith("/api/admin") or path.endswith("admin.html"):
response.headers["X-Robots-Tag"] = "noindex, nofollow"
return response
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
if not request.url.path.startswith("/api/"):
return await call_next(request)
client_ip = request.client.host if request.client else "unknown"
now = time.time()
limit = 20 if request.url.path == "/api/admin/login" else RATE_LIMIT_PER_MINUTE
bucket = _rate_buckets[client_ip]
_rate_buckets[client_ip] = [ts for ts in bucket if now - ts < 60]
if len(_rate_buckets[client_ip]) >= limit:
return JSONResponse(status_code=429, content={"detail": "Rate limit exceeded"})
_rate_buckets[client_ip].append(now)
return await call_next(request)
def utc_now() -> datetime:
return datetime.now(timezone.utc)
@app.get("/api/health")
async def health() -> dict[str, Any]:
return {
"status": "ok",
"service": APP_NAME,
"version": APP_VERSION,
"time_utc": utc_now().isoformat(),
}
@app.get("/api/dashboard")
async def dashboard(
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
local_only: bool = Query(True),
) -> dict[str, Any]:
try:
return await build_dashboard(lat, lon, local_only=local_only)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Dashboard unavailable: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Dashboard error: {exc}") from exc
@app.get("/api/location/default")
async def default_location() -> Location:
return Location()
@app.get("/api/geocode/search")
async def geocode_search(q: str = Query(..., min_length=2), limit: int = Query(8, ge=1, le=15)) -> dict[str, Any]:
try:
results = await search_places(q, limit=limit)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Geocoding unavailable: {exc}") from exc
return {"query": q, "count": len(results), "results": results}
@app.get("/api/geocode/reverse")
async def geocode_reverse(
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
) -> dict[str, Any]:
try:
result = await reverse_geocode(lat, lon)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Reverse geocoding unavailable: {exc}") from exc
return {"lat": lat, "lon": lon, "result": result}
@app.get("/api/forecast")
async def forecast(
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
) -> dict[str, Any]:
try:
return await get_forecast(lat, lon)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Forecast source unavailable: {exc}") from exc
@app.get("/api/observations")
async def observations(
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
) -> dict[str, Any]:
try:
return await get_observations(lat, lon)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Observation source unavailable: {exc}") from exc
@app.get("/api/warnings")
async def warnings(
lat: float = Query(DEFAULT_LAT, ge=-90, le=90),
lon: float = Query(DEFAULT_LON, ge=-180, le=180),
local_only: bool = Query(True),
) -> dict[str, Any]:
try:
return await fetch_warnings_for_location(lat, lon, local_only=local_only)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"Warnings unavailable: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Warnings parse error: {exc}") from exc
@app.get("/api/radar/latest")
async def radar_latest(
product: str = Query(RADAR_PRODUCT, pattern="^[a-z0-9_-]+$"),
limit: int = Query(12, ge=1, le=100),
) -> dict[str, Any]:
try:
return await fetch_radar_index(product=product, limit=limit)
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"DWD radar index unavailable: {exc}") from exc
@app.get("/api/radar/wms")
async def radar_wms() -> dict[str, Any]:
return {
"service_url": DWD_WMS_URL,
"attribution": "Radar/Warnungen: Deutscher Wetterdienst (DWD)",
"default_layer": "dwd:Niederschlagsradar",
"layers": [
{
"id": "niederschlagsradar",
"title": "Niederschlagsradar",
"layer": "dwd:Niederschlagsradar",
"opacity": 0.75,
"enabled": True,
"animated": True,
},
{
"id": "warnungen_gemeinden",
"title": "DWD Warnungen (Gemeinden)",
"layer": "dwd:Warnungen_Gemeinden_vereinigt",
"opacity": 0.6,
"enabled": True,
"animated": False,
},
{
"id": "radolan_ry",
"title": "RADOLAN RY",
"layer": "dwd:RADOLAN-RY",
"opacity": 0.72,
"enabled": False,
"animated": False,
},
{
"id": "radar_wn",
"title": "Radar WN Reflektivität",
"layer": "dwd:Radar_wn-product_1x1km_ger",
"opacity": 0.68,
"enabled": False,
"animated": True,
},
{
"id": "warnungen_landkreise",
"title": "DWD Warnungen Landkreise",
"layer": "dwd:Warnungen_Landkreise",
"opacity": 0.55,
"enabled": False,
"animated": False,
},
],
}
@app.get("/api/radar/wms/times")
async def radar_wms_times(
layer: str = Query("dwd:Niederschlagsradar"),
minutes: int = Query(120, ge=15, le=180),
) -> dict[str, Any]:
try:
return await fetch_wms_time_steps(layer, minutes=minutes)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"WMS time dimension unavailable: {exc}") from exc