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.

This commit is contained in:
2026-08-09 15:38:27 +00:00
parent 609a074fd5
commit 9a8f3f8d06

121
src/monitor_service/api.py Normal file
View File

@@ -0,0 +1,121 @@
from contextlib import asynccontextmanager
from typing import AsyncIterator
from uuid import UUID
import httpx
from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from .checker import EndpointChecker, SecurityCheckError
from .config import Settings, get_settings
from .logging import configure_logging
from .models import CheckResult, CurrentStatus, Monitor, MonitorCreate, MonitorState, MonitorUpdate, utc_now
from .store import MonitorStore
def create_app(settings: Settings | None = None, checker: EndpointChecker | None = None) -> FastAPI:
config = settings or get_settings()
store = MonitorStore()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
configure_logging(config.log_level)
if checker is not None:
app.state.checker = checker
app.state.http_client = None
else:
timeout = httpx.Timeout(config.read_timeout_seconds, connect=config.connect_timeout_seconds,
pool=config.pool_timeout_seconds)
client = httpx.AsyncClient(timeout=timeout, headers={"User-Agent": config.user_agent})
app.state.http_client = client
app.state.checker = EndpointChecker(client, config)
app.state.ready = True
yield
app.state.ready = False
if app.state.http_client is not None:
await app.state.http_client.aclose()
app = FastAPI(title=config.app_name, version="1.0.0", lifespan=lifespan)
app.state.store = store
app.state.ready = False
@app.exception_handler(HTTPException)
async def http_error(_: Request, exc: HTTPException) -> JSONResponse:
detail = exc.detail if isinstance(exc.detail, str) else "request failed"
return JSONResponse(status_code=exc.status_code,
content={"error": {"code": f"http_{exc.status_code}", "message": detail}})
@app.exception_handler(RequestValidationError)
async def validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
return JSONResponse(status_code=422, content={"error": {
"code": "validation_error", "message": "request validation failed", "details": exc.errors()
}})
def missing() -> HTTPException:
return HTTPException(status_code=404, detail="monitor not found")
@app.post("/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED)
async def create_monitor(body: MonitorCreate) -> Monitor:
return await store.create(body)
@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.patch("/monitors/{monitor_id}", response_model=Monitor)
async def update_monitor(monitor_id: UUID, body: MonitorUpdate) -> Monitor:
item = await store.update(monitor_id, body)
if item is None:
raise missing()
return item
@app.delete("/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_monitor(monitor_id: UUID) -> Response:
if not await store.delete(monitor_id):
raise missing()
return Response(status_code=204)
@app.get("/monitors/{monitor_id}/status", response_model=CurrentStatus)
async def current_status(monitor_id: UUID) -> CurrentStatus:
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 run_check(monitor_id: UUID, request: Request) -> CheckResult:
item = await store.get(monitor_id)
if item is None:
raise missing()
try:
outcome = await request.app.state.checker.check(monitor_id, str(item.url))
except SecurityCheckError as exc:
blocked = CurrentStatus(state=MonitorState.down, checked_at=utc_now(), error="security_policy")
await store.set_status(monitor_id, blocked)
raise HTTPException(status_code=400, detail=str(exc)) from exc
if await store.set_status(monitor_id, outcome.status) is None:
raise missing()
return CheckResult(monitor_id=monitor_id, url=outcome.final_url, status=outcome.status)
@app.get("/healthz")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/readyz")
async def ready(request: Request) -> dict[str, str]:
if not request.app.state.ready or not hasattr(request.app.state, "checker"):
raise HTTPException(status_code=503, detail="service not ready")
return {"status": "ready", "storage": "process-local-memory"}
return app
app = create_app()