import logging from contextlib import asynccontextmanager from uuid import UUID from fastapi import FastAPI, HTTPException, Response, status from .checker import check_url from .config import get_settings from .logging import log_event from .models import CheckResult, CheckSnapshot, Monitor, MonitorInput from .security import UnsafeDestination from .store import MonitorStore, StoreFullError settings = get_settings() logging.basicConfig(level=settings.log_level, format="%(message)s") logger = logging.getLogger("endpoint_monitor.api") @asynccontextmanager async def lifespan(app: FastAPI): # type: ignore[no-untyped-def] app.state.store = MonitorStore(settings.max_monitors) log_event(logger, "service_started", storage="process-local-memory") yield app = FastAPI(title="Endpoint Monitor", version="0.1.0", lifespan=lifespan) def store() -> MonitorStore: return app.state.store def missing() -> HTTPException: return HTTPException(status_code=404, detail="monitor not found") @app.get("/healthz") async def health() -> dict[str, str]: return {"status": "ok"} @app.get("/readyz") async def ready() -> dict[str, str]: return {"status": "ready", "storage": "process-local-memory"} @app.post("/monitors", response_model=Monitor, status_code=201) async def create_monitor(data: MonitorInput) -> Monitor: try: return await store().create(data) except StoreFullError as exc: raise HTTPException(status_code=503, detail="monitor capacity reached") from exc @app.get("/monitors", response_model=list[Monitor]) async def list_monitors() -> list[Monitor]: return await store().list() @app.get("/monitors/{monitor_id}", response_model=Monitor) async def get_monitor(monitor_id: UUID) -> Monitor: item = await store().get(monitor_id) if item is None: raise missing() return item @app.put("/monitors/{monitor_id}", response_model=Monitor) async def replace_monitor(monitor_id: UUID, data: MonitorInput) -> Monitor: item = await store().replace(monitor_id, data) if item is None: raise missing() return item @app.delete("/monitors/{monitor_id}", status_code=204) async def delete_monitor(monitor_id: UUID) -> Response: if not await store().delete(monitor_id): raise missing() return Response(status_code=status.HTTP_204_NO_CONTENT) @app.get("/monitors/{monitor_id}/status", response_model=CheckSnapshot) async def current_status(monitor_id: UUID) -> CheckSnapshot: item = await store().get(monitor_id) if item is None: raise missing() return item.status @app.post("/monitors/{monitor_id}/check", response_model=CheckResult) async def check_monitor(monitor_id: UUID) -> CheckResult: item = await store().get(monitor_id) if item is None: raise missing() timeout = item.timeout_seconds or settings.request_timeout_seconds try: snapshot = await check_url(str(item.url), timeout, settings) except UnsafeDestination as exc: snapshot = CheckSnapshot(state="error", checked_at=__import__( "datetime").datetime.now(__import__("datetime").UTC), error=str(exc)) await store().set_status(item.id, item.revision, snapshot) raise HTTPException(status_code=400, detail=str(exc)) from exc updated = await store().set_status(item.id, item.revision, snapshot) if updated is None: raise HTTPException(status_code=409, detail="monitor changed during check") return CheckResult(monitor_id=item.id, **snapshot.model_dump())