from contextlib import asynccontextmanager from typing import AsyncIterator from uuid import UUID import httpx from fastapi import FastAPI, HTTPException, Request, Response, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from .checker import EndpointChecker, SecurityCheckError from .config import Settings, get_settings from .logging import configure_logging from .models import CheckResult, CurrentStatus, Monitor, MonitorCreate, MonitorState, MonitorUpdate, utc_now from .store import MonitorStore def create_app(settings: Settings | None = None, checker: EndpointChecker | None = None) -> FastAPI: config = settings or get_settings() store = MonitorStore() @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: configure_logging(config.log_level) if checker is not None: app.state.checker = checker app.state.http_client = None else: timeout = httpx.Timeout(config.read_timeout_seconds, connect=config.connect_timeout_seconds, pool=config.pool_timeout_seconds) client = httpx.AsyncClient(timeout=timeout, headers={"User-Agent": config.user_agent}) app.state.http_client = client app.state.checker = EndpointChecker(client, config) app.state.ready = True yield app.state.ready = False if app.state.http_client is not None: await app.state.http_client.aclose() app = FastAPI(title=config.app_name, version="1.0.0", lifespan=lifespan) app.state.store = store app.state.ready = False @app.exception_handler(HTTPException) async def http_error(_: Request, exc: HTTPException) -> JSONResponse: detail = exc.detail if isinstance(exc.detail, str) else "request failed" return JSONResponse(status_code=exc.status_code, content={"error": {"code": f"http_{exc.status_code}", "message": detail}}) @app.exception_handler(RequestValidationError) async def validation_error(_: Request, exc: RequestValidationError) -> JSONResponse: return JSONResponse(status_code=422, content={"error": { "code": "validation_error", "message": "request validation failed", "details": exc.errors() }}) def missing() -> HTTPException: return HTTPException(status_code=404, detail="monitor not found") @app.post("/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED) async def create_monitor(body: MonitorCreate) -> Monitor: return await store.create(body) @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.patch("/monitors/{monitor_id}", response_model=Monitor) async def update_monitor(monitor_id: UUID, body: MonitorUpdate) -> Monitor: item = await store.update(monitor_id, body) if item is None: raise missing() return item @app.delete("/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_monitor(monitor_id: UUID) -> Response: if not await store.delete(monitor_id): raise missing() return Response(status_code=204) @app.get("/monitors/{monitor_id}/status", response_model=CurrentStatus) async def current_status(monitor_id: UUID) -> CurrentStatus: 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 run_check(monitor_id: UUID, request: Request) -> CheckResult: item = await store.get(monitor_id) if item is None: raise missing() try: outcome = await request.app.state.checker.check(monitor_id, str(item.url)) except SecurityCheckError as exc: blocked = CurrentStatus(state=MonitorState.down, checked_at=utc_now(), error="security_policy") await store.set_status(monitor_id, blocked) raise HTTPException(status_code=400, detail=str(exc)) from exc if await store.set_status(monitor_id, outcome.status) is None: raise missing() return CheckResult(monitor_id=monitor_id, url=outcome.final_url, status=outcome.status) @app.get("/healthz") async def health() -> dict[str, str]: return {"status": "ok"} @app.get("/readyz") async def ready(request: Request) -> dict[str, str]: if not request.app.state.ready or not hasattr(request.app.state, "checker"): raise HTTPException(status_code=503, detail="service not ready") return {"status": "ready", "storage": "process-local-memory"} return app app = create_app()