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:
153
app/main.py
153
app/main.py
@@ -1,105 +1,96 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator, cast
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
|
||||
from fastapi import FastAPI, HTTPException, Response, status
|
||||
|
||||
from app.checker import EndpointChecker, UnsafeTarget
|
||||
from app.config import Settings, get_settings
|
||||
from app.logging import configure_logging
|
||||
from app.models import (CheckResult, CheckStatus, HealthResponse, Monitor,
|
||||
MonitorCreate, MonitorUpdate)
|
||||
from app.store import MonitorStore, Snapshot
|
||||
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)
|
||||
|
||||
def error(status_code: int, code: str, message: str) -> HTTPException:
|
||||
return HTTPException(status_code=status_code,
|
||||
detail={"code": code, "message": message})
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
config = settings or get_settings()
|
||||
configure_logging(config.log_level)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
|
||||
application.state.store = MonitorStore()
|
||||
async def lifespan(app: FastAPI):
|
||||
app.state.store = MonitorStore()
|
||||
app.state.ready = True
|
||||
async with httpx.AsyncClient() as client:
|
||||
application.state.checker = EndpointChecker(client, config)
|
||||
app.state.checker = EndpointChecker(client, settings.request_timeout_seconds, settings.max_redirects, settings.max_response_bytes)
|
||||
yield
|
||||
app.state.ready = False
|
||||
|
||||
application = FastAPI(title=config.app_name, version="1.0.0", lifespan=lifespan)
|
||||
|
||||
def store(request: Request) -> MonitorStore:
|
||||
return cast(MonitorStore, request.app.state.store)
|
||||
app = FastAPI(title=settings.app_name, version="1.0.0", lifespan=lifespan)
|
||||
|
||||
async def existing(monitor_id: UUID, repository: MonitorStore = Depends(store)) -> Snapshot:
|
||||
snapshot = await repository.snapshot(monitor_id)
|
||||
if snapshot is None:
|
||||
raise error(404, "monitor_not_found", "Monitor not found")
|
||||
return snapshot
|
||||
|
||||
@application.post("/v1/monitors", response_model=Monitor,
|
||||
status_code=status.HTTP_201_CREATED)
|
||||
async def create_monitor(data: MonitorCreate,
|
||||
repository: MonitorStore = Depends(store)) -> Monitor:
|
||||
return await repository.create(data)
|
||||
def missing() -> HTTPException:
|
||||
return HTTPException(status_code=404, detail="monitor not found")
|
||||
|
||||
@application.get("/v1/monitors", response_model=list[Monitor])
|
||||
async def list_monitors(repository: MonitorStore = Depends(store)) -> list[Monitor]:
|
||||
return await repository.list()
|
||||
|
||||
@application.get("/v1/monitors/{monitor_id}", response_model=Monitor)
|
||||
async def get_monitor(snapshot: Snapshot = Depends(existing)) -> Monitor:
|
||||
return snapshot.monitor
|
||||
@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)
|
||||
|
||||
@application.patch("/v1/monitors/{monitor_id}", response_model=Monitor)
|
||||
async def update_monitor(monitor_id: UUID, data: MonitorUpdate,
|
||||
repository: MonitorStore = Depends(store)) -> Monitor:
|
||||
item = await repository.update(monitor_id, 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 error(404, "monitor_not_found", "Monitor not found")
|
||||
raise missing()
|
||||
return item
|
||||
|
||||
@application.delete("/v1/monitors/{monitor_id}", status_code=204)
|
||||
async def delete_monitor(monitor_id: UUID,
|
||||
repository: MonitorStore = Depends(store)) -> Response:
|
||||
if not await repository.delete(monitor_id):
|
||||
raise error(404, "monitor_not_found", "Monitor not found")
|
||||
return Response(status_code=204)
|
||||
|
||||
@application.post("/v1/monitors/{monitor_id}/checks", response_model=CheckResult)
|
||||
async def run_check(request: Request, snapshot: Snapshot = Depends(existing),
|
||||
repository: MonitorStore = Depends(store)) -> CheckResult:
|
||||
checker = cast(EndpointChecker, request.app.state.checker)
|
||||
try:
|
||||
check_status = await checker.check(str(snapshot.monitor.url))
|
||||
except UnsafeTarget as exc:
|
||||
blocked = CheckStatus(state="error", error=str(exc))
|
||||
await repository.apply_status(snapshot.monitor.id, snapshot.revision, blocked)
|
||||
raise error(400, "unsafe_target", str(exc)) from exc
|
||||
applied = await repository.apply_status(
|
||||
snapshot.monitor.id, snapshot.revision, check_status
|
||||
)
|
||||
return CheckResult(monitor_id=snapshot.monitor.id, status_applied=applied,
|
||||
**check_status.model_dump())
|
||||
|
||||
@application.get("/v1/monitors/{monitor_id}/status", response_model=CheckStatus)
|
||||
async def current_status(snapshot: Snapshot = Depends(existing)) -> CheckStatus:
|
||||
return snapshot.monitor.status
|
||||
|
||||
@application.get("/healthz", response_model=HealthResponse)
|
||||
async def health() -> HealthResponse:
|
||||
return HealthResponse(status="ok")
|
||||
|
||||
@application.get("/readyz", response_model=HealthResponse)
|
||||
async def ready(request: Request) -> HealthResponse:
|
||||
if not hasattr(request.app.state, "store") or not hasattr(request.app.state, "checker"):
|
||||
raise error(503, "not_ready", "Service dependencies are not initialized")
|
||||
return HealthResponse(status="ready")
|
||||
|
||||
return application
|
||||
@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 = create_app()
|
||||
@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")
|
||||
|
||||
Reference in New Issue
Block a user