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 / container (push) Has been cancelled
ci / quality (push) Has been cancelled

This commit is contained in:
2026-08-09 16:03:49 +00:00
parent 7a2e91aa80
commit e7e78a9f50

View File

@@ -1,105 +1,96 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import AsyncIterator, cast
from uuid import UUID from uuid import UUID
import httpx import httpx
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status from fastapi import FastAPI, HTTPException, Response, status
from app.checker import EndpointChecker, UnsafeTarget from .checker import EndpointChecker
from app.config import Settings, get_settings from .config import get_settings
from app.logging import configure_logging from .logging_config import configure_logging
from app.models import (CheckResult, CheckStatus, HealthResponse, Monitor, from .models import Monitor, MonitorCreate, MonitorUpdate, ProbeResponse, StatusResponse
MonitorCreate, MonitorUpdate) from .store import MonitorStore
from app.store import MonitorStore, Snapshot
settings = get_settings()
configure_logging(settings.log_level)
def error(status_code: int, code: str, message: str) -> HTTPException: @asynccontextmanager
return HTTPException(status_code=status_code, async def lifespan(app: FastAPI):
detail={"code": code, "message": message}) app.state.store = MonitorStore()
app.state.ready = True
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: async with httpx.AsyncClient() as client:
application.state.checker = EndpointChecker(client, config) app.state.checker = EndpointChecker(client, settings.request_timeout_seconds, settings.max_redirects, settings.max_response_bytes)
yield yield
app.state.ready = False
application = FastAPI(title=config.app_name, version="1.0.0", lifespan=lifespan)
def store(request: Request) -> MonitorStore: app = FastAPI(title=settings.app_name, version="1.0.0", lifespan=lifespan)
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, def missing() -> HTTPException:
status_code=status.HTTP_201_CREATED) return HTTPException(status_code=404, detail="monitor not found")
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) @app.post("/api/v1/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED)
async def get_monitor(snapshot: Snapshot = Depends(existing)) -> Monitor: async def create_monitor(data: MonitorCreate) -> Monitor:
return snapshot.monitor return await app.state.store.create(data)
@application.patch("/v1/monitors/{monitor_id}", response_model=Monitor)
async def update_monitor(monitor_id: UUID, data: MonitorUpdate, @app.get("/api/v1/monitors", response_model=list[Monitor])
repository: MonitorStore = Depends(store)) -> Monitor: async def list_monitors() -> list[Monitor]:
item = await repository.update(monitor_id, data) return await app.state.store.list()
@app.get("/api/v1/monitors/{monitor_id}", response_model=Monitor)
async def get_monitor(monitor_id: UUID) -> Monitor:
item = await app.state.store.get(monitor_id)
if item is None: if item is None:
raise error(404, "monitor_not_found", "Monitor not found") raise missing()
return item 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) @app.put("/api/v1/monitors/{monitor_id}", response_model=Monitor)
async def run_check(request: Request, snapshot: Snapshot = Depends(existing), async def update_monitor(monitor_id: UUID, data: MonitorUpdate) -> Monitor:
repository: MonitorStore = Depends(store)) -> CheckResult: item = await app.state.store.update(monitor_id, data)
checker = cast(EndpointChecker, request.app.state.checker) if item is None:
try: raise missing()
check_status = await checker.check(str(snapshot.monitor.url)) return item
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() @app.delete("/api/v1/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_monitor(monitor_id: UUID) -> Response:
if not await app.state.store.delete(monitor_id):
raise missing()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@app.post("/api/v1/monitors/{monitor_id}/check", response_model=Monitor)
async def check_monitor(monitor_id: UUID) -> Monitor:
item = await app.state.store.get(monitor_id)
if item is None:
raise missing()
result = await app.state.checker.check(str(monitor_id), str(item.url))
updated = await app.state.store.record(monitor_id, result)
if updated is None:
raise missing()
return updated
@app.get("/api/v1/monitors/{monitor_id}/status", response_model=StatusResponse)
async def monitor_status(monitor_id: UUID) -> StatusResponse:
item = await app.state.store.get(monitor_id)
if item is None:
raise missing()
return StatusResponse(id=item.id, status=item.status, checked_at=item.checked_at or item.created_at, latency_ms=item.latency_ms or 0, http_status=item.http_status, error=item.error)
@app.get("/healthz", response_model=ProbeResponse)
async def health() -> ProbeResponse:
return ProbeResponse(status="ok")
@app.get("/readyz", response_model=ProbeResponse)
async def ready() -> ProbeResponse:
if not getattr(app.state, "ready", False):
raise HTTPException(status_code=503, detail="not ready")
return ProbeResponse(status="ready")