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
Some checks failed
ci / validate (push) Has been cancelled
This commit is contained in:
189
app/api.py
189
app/api.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user