decomposer: generate deliverable files for Define the service contract and project architecture for the FastAPI endpoint monitoring service.; Implement the typed monitor CRUD API and concurrency-safe in-memory state according to the service design.; Implement secure on-demand endpoint checks with status updates, latency measurement, robust error handling, and redacted structured logs.; Add operational API endpoints and environment-driven runtime configuration to the monitoring service.; Create automated tests for the monitoring service.; Package the service with Docker and developer documentation.; Validate the complete project.
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
2026-08-09 15:57:46 +00:00
parent 16da6032bf
commit a1fe578781

View File

@@ -1,107 +1,79 @@
import logging
from contextlib import asynccontextmanager
from uuid import UUID from uuid import UUID
from fastapi import FastAPI, HTTPException, Response, status from fastapi import FastAPI, HTTPException, Response, status
from .checker import check_url from app.checker import EndpointChecker
from .config import get_settings from app.config import Settings, get_settings
from .logging import log_event from app.logging_config import configure_logging
from .models import CheckResult, CheckSnapshot, Monitor, MonitorInput from app.models import Health, Monitor, MonitorCreate, MonitorUpdate, StatusSnapshot
from .security import UnsafeDestination from app.store import MonitorStore
from .store import MonitorStore, StoreFullError
settings = get_settings()
logging.basicConfig(level=settings.log_level, format="%(message)s")
logger = logging.getLogger("endpoint_monitor.api")
@asynccontextmanager def create_app(
async def lifespan(app: FastAPI): # type: ignore[no-untyped-def] settings: Settings | None = None, store: MonitorStore | None = None,
app.state.store = MonitorStore(settings.max_monitors) checker: EndpointChecker | None = None,
log_event(logger, "service_started", storage="process-local-memory") ) -> FastAPI:
yield config = settings or get_settings()
configure_logging(config.log_level)
state = store or MonitorStore()
service = checker or EndpointChecker(config)
api = FastAPI(title=config.app_name, version="1.0.0")
async def existing(monitor_id: UUID) -> Monitor:
monitor = await state.get(monitor_id)
if monitor is None:
raise HTTPException(status_code=404, detail="monitor not found")
return monitor
@api.post("/monitors", response_model=Monitor, status_code=201)
async def create_monitor(body: MonitorCreate) -> Monitor:
return await state.create(body)
@api.get("/monitors", response_model=list[Monitor])
async def list_monitors() -> list[Monitor]:
return await state.list()
@api.get("/monitors/{monitor_id}", response_model=Monitor)
async def get_monitor(monitor_id: UUID) -> Monitor:
return await existing(monitor_id)
@api.put("/monitors/{monitor_id}", response_model=Monitor)
async def update_monitor(monitor_id: UUID, body: MonitorUpdate) -> Monitor:
monitor = await state.update(monitor_id, body)
if monitor is None:
raise HTTPException(status_code=404, detail="monitor not found")
return monitor
@api.delete("/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_monitor(monitor_id: UUID) -> Response:
if not await state.delete(monitor_id):
raise HTTPException(status_code=404, detail="monitor not found")
return Response(status_code=204)
@api.post("/monitors/{monitor_id}/check", response_model=StatusSnapshot)
async def check_monitor(monitor_id: UUID) -> StatusSnapshot:
pending = await state.begin_check(monitor_id)
if pending is None:
raise HTTPException(status_code=404, detail="monitor not found")
monitor, revision = pending
result = await service.check(str(monitor.id), str(monitor.url))
await state.finish_check(monitor_id, revision, result)
return result
@api.get("/monitors/{monitor_id}/status", response_model=StatusSnapshot)
async def current_status(monitor_id: UUID) -> StatusSnapshot:
return (await existing(monitor_id)).status
@api.get("/health/live", response_model=Health)
async def liveness() -> Health:
return Health(status="ok")
@api.get("/health/ready", response_model=Health)
async def readiness() -> Health:
await state.list()
return Health(status="ready")
return api
app = FastAPI(title="Endpoint Monitor", version="0.1.0", lifespan=lifespan) app = create_app()
def store() -> MonitorStore:
return app.state.store
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 ready() -> dict[str, str]:
return {"status": "ready", "storage": "process-local-memory"}
@app.post("/monitors", response_model=Monitor, status_code=201)
async def create_monitor(data: MonitorInput) -> Monitor:
try:
return await store().create(data)
except StoreFullError as exc:
raise HTTPException(status_code=503, detail="monitor capacity reached") from exc
@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.put("/monitors/{monitor_id}", response_model=Monitor)
async def replace_monitor(monitor_id: UUID, data: MonitorInput) -> Monitor:
item = await store().replace(monitor_id, data)
if item is None:
raise missing()
return item
@app.delete("/monitors/{monitor_id}", status_code=204)
async def delete_monitor(monitor_id: UUID) -> Response:
if not await store().delete(monitor_id):
raise missing()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@app.get("/monitors/{monitor_id}/status", response_model=CheckSnapshot)
async def current_status(monitor_id: UUID) -> CheckSnapshot:
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 check_monitor(monitor_id: UUID) -> CheckResult:
item = await store().get(monitor_id)
if item is None:
raise missing()
timeout = item.timeout_seconds or settings.request_timeout_seconds
try:
snapshot = await check_url(str(item.url), timeout, settings)
except UnsafeDestination as exc:
snapshot = CheckSnapshot(state="error", checked_at=__import__(
"datetime").datetime.now(__import__("datetime").UTC), error=str(exc))
await store().set_status(item.id, item.revision, snapshot)
raise HTTPException(status_code=400, detail=str(exc)) from exc
updated = await store().set_status(item.id, item.revision, snapshot)
if updated is None:
raise HTTPException(status_code=409, detail="monitor changed during check")
return CheckResult(monitor_id=item.id, **snapshot.model_dump())