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:49:44 +00:00
parent fbcea5285a
commit ae9cdc3427

View File

@@ -1,117 +1,101 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime from typing import AsyncIterator
from typing import Annotated, AsyncIterator
from uuid import UUID from uuid import UUID
from fastapi import Depends, FastAPI, HTTPException, Response, status from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
from app.checker import EndpointChecker from app.checker import EndpointChecker
from app.config import get_settings from app.config import Settings, get_settings
from app.logging import configure_logging from app.logging import configure_logging
from app.models import CurrentStatus, Monitor, MonitorCreate, MonitorUpdate, State from app.models import CheckResponse, CurrentStatus, Monitor, MonitorCreate, MonitorUpdate
from app.security import DestinationRejected
from app.store import MonitorStore from app.store import MonitorStore
def not_found() -> HTTPException:
return HTTPException(status_code=404, detail={"code": "monitor_not_found", "message": "monitor does not exist"})
def get_store(request: Request) -> MonitorStore:
return request.app.state.store
def get_checker(request: Request) -> EndpointChecker:
return request.app.state.checker
def create_app(
settings: Settings | None = None,
store: MonitorStore | None = None,
checker: EndpointChecker | None = None,
) -> FastAPI:
config = settings or get_settings()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings() configure_logging(config.log_level)
configure_logging(settings.log_level)
app.state.store = MonitorStore()
app.state.checker = EndpointChecker(settings)
app.state.ready = True
yield yield
app.state.ready = False
app = FastAPI(title="Endpoint Monitor Service", version="0.1.0", lifespan=lifespan)
app.state.store = store or MonitorStore()
app.state.checker = checker or EndpointChecker(config)
app = FastAPI(title="Endpoint Monitor", version="0.1.0", lifespan=lifespan) @app.get("/health")
def store() -> MonitorStore:
return app.state.store # type: ignore[no-any-return]
def checker() -> EndpointChecker:
return app.state.checker # type: ignore[no-any-return]
Store = Annotated[MonitorStore, Depends(store)]
Checker = Annotated[EndpointChecker, Depends(checker)]
def missing() -> HTTPException:
return HTTPException(status_code=404, detail="monitor not found")
@app.get("/healthz")
async def health() -> dict[str, str]: async def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@app.get("/ready")
async def ready() -> dict[str, str]:
return {"status": "ready", "storage": "process-local-memory"}
@app.get("/readyz") @app.post("/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED)
async def readiness() -> dict[str, str]: async def create_monitor(value: MonitorCreate, state: MonitorStore = Depends(get_store)) -> Monitor:
if not getattr(app.state, "ready", False): return await state.create(value)
raise HTTPException(status_code=503, detail="not ready")
return {"status": "ready"}
@app.get("/monitors", response_model=list[Monitor])
@app.post("/v1/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED) async def list_monitors(state: MonitorStore = Depends(get_store)) -> list[Monitor]:
async def create_monitor(payload: MonitorCreate, state: Store) -> Monitor:
return await state.create(payload)
@app.get("/v1/monitors", response_model=list[Monitor])
async def list_monitors(state: Store) -> list[Monitor]:
return await state.list() return await state.list()
@app.get("/monitors/{monitor_id}", response_model=Monitor)
@app.get("/v1/monitors/{monitor_id}", response_model=Monitor) async def read_monitor(monitor_id: UUID, state: MonitorStore = Depends(get_store)) -> Monitor:
async def get_monitor(monitor_id: UUID, state: Store) -> Monitor:
item = await state.get(monitor_id) item = await state.get(monitor_id)
if item is None: if item is None:
raise missing() raise not_found()
return item return item
@app.put("/monitors/{monitor_id}", response_model=Monitor)
@app.patch("/v1/monitors/{monitor_id}", response_model=Monitor) async def update_monitor(monitor_id: UUID, value: MonitorUpdate, state: MonitorStore = Depends(get_store)) -> Monitor:
async def update_monitor(monitor_id: UUID, payload: MonitorUpdate, state: Store) -> Monitor: item = await state.update(monitor_id, value)
if not payload.model_fields_set:
raise HTTPException(status_code=422, detail="at least one field is required")
item = await state.update(monitor_id, payload)
if item is None: if item is None:
raise missing() raise not_found()
return item return item
@app.delete("/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT)
@app.delete("/v1/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_monitor(monitor_id: UUID, state: MonitorStore = Depends(get_store)) -> Response:
async def delete_monitor(monitor_id: UUID, state: Store) -> Response:
if not await state.delete(monitor_id): if not await state.delete(monitor_id):
raise missing() raise not_found()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@app.post("/monitors/{monitor_id}/check", response_model=CheckResponse)
async def run_check(
monitor_id: UUID,
state: MonitorStore = Depends(get_store),
endpoint_checker: EndpointChecker = Depends(get_checker),
) -> CheckResponse:
snapshot = await state.get(monitor_id)
if snapshot is None:
raise not_found()
result = await endpoint_checker.check(str(snapshot.url), str(monitor_id))
applied = await state.set_status_if_current(monitor_id, snapshot.revision, result)
return CheckResponse(monitor_id=monitor_id, status=result, applied=applied)
@app.get("/v1/monitors/{monitor_id}/status", response_model=CurrentStatus) @app.get("/monitors/{monitor_id}/status", response_model=CurrentStatus | None)
async def current_status(monitor_id: UUID, state: Store) -> CurrentStatus: async def current_status(monitor_id: UUID, state: MonitorStore = Depends(get_store)) -> CurrentStatus | None:
item = await state.get(monitor_id) item = await state.get(monitor_id)
if item is None: if item is None:
raise missing() raise not_found()
return item.current_status return item.current_status
return app
@app.post("/v1/monitors/{monitor_id}/check", response_model=CurrentStatus)
async def check_monitor(monitor_id: UUID, state: Store, worker: Checker) -> CurrentStatus: app = create_app()
item = await state.get(monitor_id)
if item is None:
raise missing()
try:
result = await worker.check(str(monitor_id), str(item.url))
except DestinationRejected as exc:
result = CurrentStatus(
state=State.ERROR, checked_at=datetime.now(UTC), error=str(exc)[:200]
)
if await state.set_status(monitor_id, result) is None:
raise missing() from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
if await state.set_status(monitor_id, result) is None:
raise missing()
return result