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:
121
src/monitor_service/api.py
Normal file
121
src/monitor_service/api.py
Normal 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()
|
||||||
Reference in New Issue
Block a user