from collections.abc import AsyncIterator from contextlib import asynccontextmanager from typing import cast from uuid import UUID from fastapi import FastAPI, HTTPException, Request, Response, status from app.checker import EndpointChecker from app.config import Settings, get_settings from app.logging_config import configure_logging from app.models import CurrentStatus, Health, Monitor, MonitorCreate, MonitorUpdate from app.security import UnsafeTarget from app.store import MonitorNotFound, MonitorStore def create_app(settings: Settings | None = None, checker: EndpointChecker | None = None) -> FastAPI: runtime_settings = settings or get_settings() @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: configure_logging(runtime_settings.log_level) app.state.store = MonitorStore() app.state.checker = checker or EndpointChecker(runtime_settings) app.state.ready = True yield app.state.ready = False app = FastAPI(title="Endpoint Monitor", version="1.0.0", lifespan=lifespan) def store(request: Request) -> MonitorStore: return cast(MonitorStore, request.app.state.store) @app.post("/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED) async def create_monitor(data: MonitorCreate, request: Request) -> Monitor: return await store(request).create(data) @app.get("/monitors", response_model=list[Monitor]) async def list_monitors(request: Request) -> list[Monitor]: return await store(request).list() @app.get("/monitors/{monitor_id}", response_model=Monitor) async def get_monitor(monitor_id: UUID, request: Request) -> Monitor: try: return await store(request).get(monitor_id) except MonitorNotFound as exc: raise HTTPException(status_code=404, detail="monitor not found") from exc @app.put("/monitors/{monitor_id}", response_model=Monitor) async def update_monitor(monitor_id: UUID, data: MonitorUpdate, request: Request) -> Monitor: try: return await store(request).update(monitor_id, data) except MonitorNotFound as exc: raise HTTPException(status_code=404, detail="monitor not found") from exc @app.delete("/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_monitor(monitor_id: UUID, request: Request) -> Response: try: await store(request).delete(monitor_id) except MonitorNotFound as exc: raise HTTPException(status_code=404, detail="monitor not found") from exc return Response(status_code=status.HTTP_204_NO_CONTENT) @app.post("/monitors/{monitor_id}/check", response_model=CurrentStatus) async def check_monitor(monitor_id: UUID, request: Request) -> CurrentStatus: monitor_store = store(request) try: monitor = await monitor_store.get(monitor_id) except MonitorNotFound as exc: raise HTTPException(status_code=404, detail="monitor not found") from exc active_checker = cast(EndpointChecker, request.app.state.checker) try: result = await active_checker.check(str(monitor.id), str(monitor.target_url)) except UnsafeTarget as exc: blocked = EndpointChecker._status(0 if False else __import__("time").monotonic(), monitor.current_status.state, error=None) del blocked # Policy failures are mapped to 400 and do not overwrite the last completed status. raise HTTPException(status_code=400, detail=str(exc)) from exc try: return await monitor_store.set_status(monitor_id, result) except MonitorNotFound as exc: raise HTTPException(status_code=404, detail="monitor deleted while check was running") from exc @app.get("/monitors/{monitor_id}/status", response_model=CurrentStatus) async def get_monitor_status(monitor_id: UUID, request: Request) -> CurrentStatus: try: return (await store(request).get(monitor_id)).current_status except MonitorNotFound as exc: raise HTTPException(status_code=404, detail="monitor not found") from exc @app.get("/health/live", response_model=Health) async def liveness() -> Health: return Health(status="ok") @app.get("/health/ready", response_model=Health) async def readiness(request: Request) -> Health: if not getattr(request.app.state, "ready", False): raise HTTPException(status_code=503, detail="not ready") return Health(status="ready") return app app = create_app()