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 / validate (push) Has been cancelled

This commit is contained in:
2026-08-09 15:44:06 +00:00
parent bef61e41c5
commit 7cf97cbe6d

View File

@@ -1,92 +1,125 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.checker import EndpointChecker
from app.models import CurrentStatus, Monitor, MonitorCreate, MonitorUpdate
from app.store import MonitorStore
from app.config import Settings, get_settings
from app.logging import configure_logging
from app.models import CheckResult, ErrorResponse, Monitor, MonitorCreate, MonitorUpdate
from app.store import MonitorNotFoundError, MonitorStore
router = APIRouter()
ERROR_RESPONSES = {
404: {"model": ErrorResponse, "description": "Monitor not found"},
422: {"model": ErrorResponse, "description": "Invalid request"},
}
def store(request: Request) -> MonitorStore:
return request.app.state.store
def create_app(
settings: Settings | None = None,
store: MonitorStore | None = None,
checker: EndpointChecker | None = None,
) -> FastAPI:
settings = settings or get_settings()
configure_logging(settings.log_level)
app = FastAPI(title="Endpoint Monitor Service", version="0.1.0")
app.state.store = store or MonitorStore()
app.state.checker = checker or EndpointChecker(
settings.check_timeout_seconds, settings.max_redirects
)
@app.exception_handler(HTTPException)
async def http_error(_: Request, exc: HTTPException) -> JSONResponse:
code = "not_found" if exc.status_code == 404 else "http_error"
return JSONResponse(
status_code=exc.status_code,
content={"error": {"code": code, "message": str(exc.detail)}},
)
def checker(request: Request) -> EndpointChecker:
return request.app.state.checker
@app.exception_handler(RequestValidationError)
async def validation_error(_: Request, __: RequestValidationError) -> JSONResponse:
return JSONResponse(
status_code=422,
content={"error": {"code": "invalid_request", "message": "request validation failed"}},
)
def missing() -> HTTPException:
return HTTPException(status_code=404, detail="monitor not found")
Store = Annotated[MonitorStore, Depends(store)]
Checker = Annotated[EndpointChecker, Depends(checker)]
def missing() -> HTTPException:
return HTTPException(status_code=404, detail=("monitor_not_found", "Monitor not found"))
@router.post("/v1/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED)
async def create_monitor(data: MonitorCreate, state: Store) -> Monitor:
return await state.create(data)
@router.get("/v1/monitors", response_model=list[Monitor])
async def list_monitors(state: Store) -> list[Monitor]:
return await state.list()
@router.get("/v1/monitors/{monitor_id}", response_model=Monitor)
async def get_monitor(monitor_id: UUID, state: Store) -> Monitor:
item = await state.get(monitor_id)
if item is None:
raise missing()
return item
@router.patch("/v1/monitors/{monitor_id}", response_model=Monitor)
async def update_monitor(monitor_id: UUID, data: MonitorUpdate, state: Store) -> Monitor:
item = await state.update(monitor_id, data)
if item is None:
raise missing()
return item
@router.delete("/v1/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_monitor(monitor_id: UUID, state: Store) -> Response:
if not await state.delete(monitor_id):
raise missing()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/v1/monitors/{monitor_id}/check", response_model=CurrentStatus)
async def check_monitor(monitor_id: UUID, state: Store, outbound: Checker) -> CurrentStatus:
snapshot = await state.snapshot(monitor_id)
if snapshot is None:
raise missing()
item, revision = snapshot
result = await outbound.check(str(item.url))
if await state.record_status(monitor_id, revision, result) is None:
raise HTTPException(status_code=409,
detail=("monitor_changed", "Monitor changed while check was running"))
return result
@router.get("/v1/monitors/{monitor_id}/status", response_model=CurrentStatus)
async def get_status(monitor_id: UUID, state: Store) -> CurrentStatus:
item = await state.get(monitor_id)
if item is None:
raise missing()
return item.current_status
@router.get("/healthz")
async def health() -> dict[str, str]:
@app.get("/health/live", tags=["operations"])
async def live() -> dict[str, str]:
return {"status": "ok"}
@router.get("/readyz")
async def readiness(request: Request) -> dict[str, str]:
if not hasattr(request.app.state, "store"):
raise HTTPException(status_code=503, detail=("not_ready", "Service is not ready"))
@app.get("/health/ready", tags=["operations"])
async def ready() -> dict[str, str]:
return {"status": "ready", "storage": "process-local-memory"}
@app.post(
"/monitors",
response_model=Monitor,
status_code=status.HTTP_201_CREATED,
responses={422: ERROR_RESPONSES[422]},
)
async def create_monitor(data: MonitorCreate) -> Monitor:
return await app.state.store.create(data)
@app.get("/monitors", response_model=list[Monitor])
async def list_monitors() -> list[Monitor]:
return await app.state.store.list()
@app.get("/monitors/{monitor_id}", response_model=Monitor, responses=ERROR_RESPONSES)
async def get_monitor(monitor_id: UUID) -> Monitor:
try:
return await app.state.store.get(monitor_id)
except MonitorNotFoundError as exc:
raise missing() from exc
@app.patch("/monitors/{monitor_id}", response_model=Monitor, responses=ERROR_RESPONSES)
async def update_monitor(monitor_id: UUID, data: MonitorUpdate) -> Monitor:
try:
return await app.state.store.update(monitor_id, data)
except MonitorNotFoundError as exc:
raise missing() from exc
@app.delete(
"/monitors/{monitor_id}",
status_code=status.HTTP_204_NO_CONTENT,
responses={404: ERROR_RESPONSES[404]},
)
async def delete_monitor(monitor_id: UUID) -> Response:
try:
await app.state.store.delete(monitor_id)
except MonitorNotFoundError as exc:
raise missing() from exc
return Response(status_code=status.HTTP_204_NO_CONTENT)
@app.get(
"/monitors/{monitor_id}/status",
response_model=CheckResult,
responses=ERROR_RESPONSES,
)
async def current_status(monitor_id: UUID) -> CheckResult:
try:
monitor = await app.state.store.get(monitor_id)
except MonitorNotFoundError as exc:
raise missing() from exc
return CheckResult(monitor_id=monitor.id, **monitor.current_status.model_dump())
@app.post(
"/monitors/{monitor_id}/check",
response_model=CheckResult,
responses=ERROR_RESPONSES,
)
async def check_monitor(monitor_id: UUID) -> CheckResult:
try:
monitor = await app.state.store.get(monitor_id)
result = await app.state.checker.check(monitor)
await app.state.store.set_status(
monitor_id, result.model_copy(update={}, deep=True)
)
return result
except MonitorNotFoundError as exc:
raise missing() from exc
return app