Files
crucible-agent-build-fastap…/app/main.py

118 lines
3.8 KiB
Python

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