from contextlib import asynccontextmanager from datetime import UTC, datetime from typing import Annotated, AsyncIterator from uuid import UUID from fastapi import Depends, FastAPI, HTTPException, Response, status from app.checker import EndpointChecker from app.config import get_settings from app.logging import configure_logging from app.models import CurrentStatus, Monitor, MonitorCreate, MonitorUpdate, State from app.security import DestinationRejected from app.store import MonitorStore @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: settings = get_settings() configure_logging(settings.log_level) app.state.store = MonitorStore() app.state.checker = EndpointChecker(settings) app.state.ready = True yield app.state.ready = False app = FastAPI(title="Endpoint Monitor", version="0.1.0", lifespan=lifespan) def store() -> MonitorStore: return app.state.store # type: ignore[no-any-return] def checker() -> EndpointChecker: return app.state.checker # type: ignore[no-any-return] Store = Annotated[MonitorStore, Depends(store)] Checker = Annotated[EndpointChecker, Depends(checker)] 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 readiness() -> dict[str, str]: if not getattr(app.state, "ready", False): raise HTTPException(status_code=503, detail="not ready") return {"status": "ready"} @app.post("/v1/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED) async def create_monitor(payload: MonitorCreate, state: Store) -> Monitor: return await state.create(payload) @app.get("/v1/monitors", response_model=list[Monitor]) async def list_monitors(state: Store) -> list[Monitor]: return await state.list() @app.get("/v1/monitors/{monitor_id}", response_model=Monitor) async def get_monitor(monitor_id: UUID, state: Store) -> Monitor: item = await state.get(monitor_id) if item is None: raise missing() return item @app.patch("/v1/monitors/{monitor_id}", response_model=Monitor) async def update_monitor(monitor_id: UUID, payload: MonitorUpdate, state: Store) -> Monitor: if not payload.model_fields_set: raise HTTPException(status_code=422, detail="at least one field is required") item = await state.update(monitor_id, payload) if item is None: raise missing() return item @app.delete("/v1/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_monitor(monitor_id: UUID, state: Store) -> Response: if not await state.delete(monitor_id): raise missing() return Response(status_code=status.HTTP_204_NO_CONTENT) @app.get("/v1/monitors/{monitor_id}/status", response_model=CurrentStatus) async def current_status(monitor_id: UUID, state: Store) -> CurrentStatus: item = await state.get(monitor_id) if item is None: raise missing() return item.current_status @app.post("/v1/monitors/{monitor_id}/check", response_model=CurrentStatus) async def check_monitor(monitor_id: UUID, state: Store, worker: Checker) -> CurrentStatus: item = await state.get(monitor_id) if item is None: raise missing() try: result = await worker.check(str(monitor_id), str(item.url)) except DestinationRejected as exc: result = CurrentStatus( state=State.ERROR, checked_at=datetime.now(UTC), error=str(exc)[:200] ) if await state.set_status(monitor_id, result) is None: raise missing() from exc raise HTTPException(status_code=400, detail=str(exc)) from exc if await state.set_status(monitor_id, result) is None: raise missing() return result