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 16:00:58 +00:00
parent dfebce19ac
commit 9a5e44f474

View File

@@ -1,79 +1,105 @@
from contextlib import asynccontextmanager
from typing import AsyncIterator, cast
from uuid import UUID from uuid import UUID
from fastapi import FastAPI, HTTPException, Response, status import httpx
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
from app.checker import EndpointChecker from app.checker import EndpointChecker, UnsafeTarget
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.logging_config import configure_logging from app.logging import configure_logging
from app.models import Health, Monitor, MonitorCreate, MonitorUpdate, StatusSnapshot from app.models import (CheckResult, CheckStatus, HealthResponse, Monitor,
from app.store import MonitorStore MonitorCreate, MonitorUpdate)
from app.store import MonitorStore, Snapshot
def create_app( def error(status_code: int, code: str, message: str) -> HTTPException:
settings: Settings | None = None, store: MonitorStore | None = None, return HTTPException(status_code=status_code,
checker: EndpointChecker | None = None, detail={"code": code, "message": message})
) -> FastAPI:
def create_app(settings: Settings | None = None) -> FastAPI:
config = settings or get_settings() config = settings or get_settings()
configure_logging(config.log_level) 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: @asynccontextmanager
monitor = await state.get(monitor_id) async def lifespan(application: FastAPI) -> AsyncIterator[None]:
if monitor is None: application.state.store = MonitorStore()
raise HTTPException(status_code=404, detail="monitor not found") async with httpx.AsyncClient() as client:
return monitor application.state.checker = EndpointChecker(client, config)
yield
@api.post("/monitors", response_model=Monitor, status_code=201) application = FastAPI(title=config.app_name, version="1.0.0", lifespan=lifespan)
async def create_monitor(body: MonitorCreate) -> Monitor:
return await state.create(body)
@api.get("/monitors", response_model=list[Monitor]) def store(request: Request) -> MonitorStore:
async def list_monitors() -> list[Monitor]: return cast(MonitorStore, request.app.state.store)
return await state.list()
@api.get("/monitors/{monitor_id}", response_model=Monitor) async def existing(monitor_id: UUID, repository: MonitorStore = Depends(store)) -> Snapshot:
async def get_monitor(monitor_id: UUID) -> Monitor: snapshot = await repository.snapshot(monitor_id)
return await existing(monitor_id) if snapshot is None:
raise error(404, "monitor_not_found", "Monitor not found")
return snapshot
@api.put("/monitors/{monitor_id}", response_model=Monitor) @application.post("/v1/monitors", response_model=Monitor,
async def update_monitor(monitor_id: UUID, body: MonitorUpdate) -> Monitor: status_code=status.HTTP_201_CREATED)
monitor = await state.update(monitor_id, body) async def create_monitor(data: MonitorCreate,
if monitor is None: repository: MonitorStore = Depends(store)) -> Monitor:
raise HTTPException(status_code=404, detail="monitor not found") return await repository.create(data)
return monitor
@api.delete("/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT) @application.get("/v1/monitors", response_model=list[Monitor])
async def delete_monitor(monitor_id: UUID) -> Response: async def list_monitors(repository: MonitorStore = Depends(store)) -> list[Monitor]:
if not await state.delete(monitor_id): return await repository.list()
raise HTTPException(status_code=404, detail="monitor not found")
@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) return Response(status_code=204)
@api.post("/monitors/{monitor_id}/check", response_model=StatusSnapshot) @application.post("/v1/monitors/{monitor_id}/checks", response_model=CheckResult)
async def check_monitor(monitor_id: UUID) -> StatusSnapshot: async def run_check(request: Request, snapshot: Snapshot = Depends(existing),
pending = await state.begin_check(monitor_id) repository: MonitorStore = Depends(store)) -> CheckResult:
if pending is None: checker = cast(EndpointChecker, request.app.state.checker)
raise HTTPException(status_code=404, detail="monitor not found") try:
monitor, revision = pending check_status = await checker.check(str(snapshot.monitor.url))
result = await service.check(str(monitor.id), str(monitor.url)) except UnsafeTarget as exc:
await state.finish_check(monitor_id, revision, result) blocked = CheckStatus(state="error", error=str(exc))
return result 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())
@api.get("/monitors/{monitor_id}/status", response_model=StatusSnapshot) @application.get("/v1/monitors/{monitor_id}/status", response_model=CheckStatus)
async def current_status(monitor_id: UUID) -> StatusSnapshot: async def current_status(snapshot: Snapshot = Depends(existing)) -> CheckStatus:
return (await existing(monitor_id)).status return snapshot.monitor.status
@api.get("/health/live", response_model=Health) @application.get("/healthz", response_model=HealthResponse)
async def liveness() -> Health: async def health() -> HealthResponse:
return Health(status="ok") return HealthResponse(status="ok")
@api.get("/health/ready", response_model=Health) @application.get("/readyz", response_model=HealthResponse)
async def readiness() -> Health: async def ready(request: Request) -> HealthResponse:
await state.list() if not hasattr(request.app.state, "store") or not hasattr(request.app.state, "checker"):
return Health(status="ready") raise error(503, "not_ready", "Service dependencies are not initialized")
return HealthResponse(status="ready")
return api return application
app = create_app() app = create_app()