from contextlib import asynccontextmanager from typing import AsyncIterator, cast from uuid import UUID import httpx from fastapi import Depends, FastAPI, HTTPException, Request, Response, status from app.checker import EndpointChecker, UnsafeTarget from app.config import Settings, get_settings from app.logging import configure_logging from app.models import (CheckResult, CheckStatus, HealthResponse, Monitor, MonitorCreate, MonitorUpdate) from app.store import MonitorStore, Snapshot def error(status_code: int, code: str, message: str) -> HTTPException: return HTTPException(status_code=status_code, detail={"code": code, "message": message}) def create_app(settings: Settings | None = None) -> FastAPI: config = settings or get_settings() configure_logging(config.log_level) @asynccontextmanager async def lifespan(application: FastAPI) -> AsyncIterator[None]: application.state.store = MonitorStore() async with httpx.AsyncClient() as client: application.state.checker = EndpointChecker(client, config) yield application = FastAPI(title=config.app_name, version="1.0.0", lifespan=lifespan) def store(request: Request) -> MonitorStore: return cast(MonitorStore, request.app.state.store) async def existing(monitor_id: UUID, repository: MonitorStore = Depends(store)) -> Snapshot: snapshot = await repository.snapshot(monitor_id) if snapshot is None: raise error(404, "monitor_not_found", "Monitor not found") return snapshot @application.post("/v1/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED) async def create_monitor(data: MonitorCreate, repository: MonitorStore = Depends(store)) -> Monitor: return await repository.create(data) @application.get("/v1/monitors", response_model=list[Monitor]) async def list_monitors(repository: MonitorStore = Depends(store)) -> list[Monitor]: return await repository.list() @application.get("/v1/monitors/{monitor_id}", response_model=Monitor) async def get_monitor(snapshot: Snapshot = Depends(existing)) -> Monitor: return snapshot.monitor @application.patch("/v1/monitors/{monitor_id}", response_model=Monitor) async def update_monitor(monitor_id: UUID, data: MonitorUpdate, repository: MonitorStore = Depends(store)) -> Monitor: item = await repository.update(monitor_id, data) if item is None: raise error(404, "monitor_not_found", "Monitor not found") return item @application.delete("/v1/monitors/{monitor_id}", status_code=204) async def delete_monitor(monitor_id: UUID, repository: MonitorStore = Depends(store)) -> Response: if not await repository.delete(monitor_id): raise error(404, "monitor_not_found", "Monitor not found") return Response(status_code=204) @application.post("/v1/monitors/{monitor_id}/checks", response_model=CheckResult) async def run_check(request: Request, snapshot: Snapshot = Depends(existing), repository: MonitorStore = Depends(store)) -> CheckResult: checker = cast(EndpointChecker, request.app.state.checker) try: check_status = await checker.check(str(snapshot.monitor.url)) except UnsafeTarget as exc: blocked = CheckStatus(state="error", error=str(exc)) await repository.apply_status(snapshot.monitor.id, snapshot.revision, blocked) raise error(400, "unsafe_target", str(exc)) from exc applied = await repository.apply_status( snapshot.monitor.id, snapshot.revision, check_status ) return CheckResult(monitor_id=snapshot.monitor.id, status_applied=applied, **check_status.model_dump()) @application.get("/v1/monitors/{monitor_id}/status", response_model=CheckStatus) async def current_status(snapshot: Snapshot = Depends(existing)) -> CheckStatus: return snapshot.monitor.status @application.get("/healthz", response_model=HealthResponse) async def health() -> HealthResponse: return HealthResponse(status="ok") @application.get("/readyz", response_model=HealthResponse) async def ready(request: Request) -> HealthResponse: if not hasattr(request.app.state, "store") or not hasattr(request.app.state, "checker"): raise error(503, "not_ready", "Service dependencies are not initialized") return HealthResponse(status="ready") return application app = create_app()