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

This commit is contained in:
2026-08-09 16:07:09 +00:00
parent 13e26308ea
commit 66f4854063

View File

@@ -1,96 +1,102 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import cast
from uuid import UUID
import httpx
from fastapi import FastAPI, HTTPException, Response, status
from fastapi import FastAPI, HTTPException, Request, Response, status
from .checker import EndpointChecker
from .config import get_settings
from .logging_config import configure_logging
from .models import Monitor, MonitorCreate, MonitorUpdate, ProbeResponse, StatusResponse
from .store import MonitorStore
settings = get_settings()
configure_logging(settings.log_level)
from app.checker import EndpointChecker
from app.config import Settings, get_settings
from app.logging_config import configure_logging
from app.models import CurrentStatus, Health, Monitor, MonitorCreate, MonitorUpdate
from app.security import UnsafeTarget
from app.store import MonitorNotFound, MonitorStore
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.store = MonitorStore()
app.state.ready = True
async with httpx.AsyncClient() as client:
app.state.checker = EndpointChecker(client, settings.request_timeout_seconds, settings.max_redirects, settings.max_response_bytes)
def create_app(settings: Settings | None = None, checker: EndpointChecker | None = None) -> FastAPI:
runtime_settings = settings or get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
configure_logging(runtime_settings.log_level)
app.state.store = MonitorStore()
app.state.checker = checker or EndpointChecker(runtime_settings)
app.state.ready = True
yield
app.state.ready = False
app.state.ready = False
app = FastAPI(title="Endpoint Monitor", version="1.0.0", lifespan=lifespan)
def store(request: Request) -> MonitorStore:
return cast(MonitorStore, request.app.state.store)
@app.post("/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED)
async def create_monitor(data: MonitorCreate, request: Request) -> Monitor:
return await store(request).create(data)
@app.get("/monitors", response_model=list[Monitor])
async def list_monitors(request: Request) -> list[Monitor]:
return await store(request).list()
@app.get("/monitors/{monitor_id}", response_model=Monitor)
async def get_monitor(monitor_id: UUID, request: Request) -> Monitor:
try:
return await store(request).get(monitor_id)
except MonitorNotFound as exc:
raise HTTPException(status_code=404, detail="monitor not found") from exc
@app.put("/monitors/{monitor_id}", response_model=Monitor)
async def update_monitor(monitor_id: UUID, data: MonitorUpdate, request: Request) -> Monitor:
try:
return await store(request).update(monitor_id, data)
except MonitorNotFound as exc:
raise HTTPException(status_code=404, detail="monitor not found") from exc
@app.delete("/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_monitor(monitor_id: UUID, request: Request) -> Response:
try:
await store(request).delete(monitor_id)
except MonitorNotFound as exc:
raise HTTPException(status_code=404, detail="monitor not found") from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)
@app.post("/monitors/{monitor_id}/check", response_model=CurrentStatus)
async def check_monitor(monitor_id: UUID, request: Request) -> CurrentStatus:
monitor_store = store(request)
try:
monitor = await monitor_store.get(monitor_id)
except MonitorNotFound as exc:
raise HTTPException(status_code=404, detail="monitor not found") from exc
active_checker = cast(EndpointChecker, request.app.state.checker)
try:
result = await active_checker.check(str(monitor.id), str(monitor.target_url))
except UnsafeTarget as exc:
blocked = EndpointChecker._status(0 if False else __import__("time").monotonic(), monitor.current_status.state, error=None)
del blocked # Policy failures are mapped to 400 and do not overwrite the last completed status.
raise HTTPException(status_code=400, detail=str(exc)) from exc
try:
return await monitor_store.set_status(monitor_id, result)
except MonitorNotFound as exc:
raise HTTPException(status_code=404, detail="monitor deleted while check was running") from exc
@app.get("/monitors/{monitor_id}/status", response_model=CurrentStatus)
async def get_monitor_status(monitor_id: UUID, request: Request) -> CurrentStatus:
try:
return (await store(request).get(monitor_id)).current_status
except MonitorNotFound as exc:
raise HTTPException(status_code=404, detail="monitor not found") from exc
@app.get("/health/live", response_model=Health)
async def liveness() -> Health:
return Health(status="ok")
@app.get("/health/ready", response_model=Health)
async def readiness(request: Request) -> Health:
if not getattr(request.app.state, "ready", False):
raise HTTPException(status_code=503, detail="not ready")
return Health(status="ready")
return app
app = FastAPI(title=settings.app_name, version="1.0.0", lifespan=lifespan)
def missing() -> HTTPException:
return HTTPException(status_code=404, detail="monitor not found")
@app.post("/api/v1/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED)
async def create_monitor(data: MonitorCreate) -> Monitor:
return await app.state.store.create(data)
@app.get("/api/v1/monitors", response_model=list[Monitor])
async def list_monitors() -> list[Monitor]:
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:
raise missing()
return item
@app.put("/api/v1/monitors/{monitor_id}", response_model=Monitor)
async def update_monitor(monitor_id: UUID, data: MonitorUpdate) -> Monitor:
item = await app.state.store.update(monitor_id, data)
if item is None:
raise missing()
return item
@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")
app = create_app()