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:
184
app/main.py
184
app/main.py
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
def not_found() -> HTTPException:
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
return HTTPException(status_code=404, detail={"code": "monitor_not_found", "message": "monitor does not exist"})
|
||||||
settings = get_settings()
|
|
||||||
configure_logging(settings.log_level)
|
|
||||||
app.state.store = MonitorStore()
|
|
||||||
app.state.checker = EndpointChecker(settings)
|
|
||||||
app.state.ready = True
|
|
||||||
yield
|
|
||||||
app.state.ready = False
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Endpoint Monitor", version="0.1.0", lifespan=lifespan)
|
def get_store(request: Request) -> MonitorStore:
|
||||||
|
return request.app.state.store
|
||||||
|
|
||||||
|
|
||||||
def store() -> MonitorStore:
|
def get_checker(request: Request) -> EndpointChecker:
|
||||||
return app.state.store # type: ignore[no-any-return]
|
return request.app.state.checker
|
||||||
|
|
||||||
|
|
||||||
def checker() -> EndpointChecker:
|
def create_app(
|
||||||
return app.state.checker # type: ignore[no-any-return]
|
settings: Settings | None = None,
|
||||||
|
store: MonitorStore | None = None,
|
||||||
|
checker: EndpointChecker | None = None,
|
||||||
|
) -> FastAPI:
|
||||||
|
config = settings or get_settings()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
configure_logging(config.log_level)
|
||||||
|
yield
|
||||||
|
|
||||||
|
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.get("/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.get("/ready")
|
||||||
|
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)
|
||||||
|
async def create_monitor(value: MonitorCreate, state: MonitorStore = Depends(get_store)) -> Monitor:
|
||||||
|
return await state.create(value)
|
||||||
|
|
||||||
|
@app.get("/monitors", response_model=list[Monitor])
|
||||||
|
async def list_monitors(state: MonitorStore = Depends(get_store)) -> list[Monitor]:
|
||||||
|
return await state.list()
|
||||||
|
|
||||||
|
@app.get("/monitors/{monitor_id}", response_model=Monitor)
|
||||||
|
async def read_monitor(monitor_id: UUID, state: MonitorStore = Depends(get_store)) -> Monitor:
|
||||||
|
item = await state.get(monitor_id)
|
||||||
|
if item is None:
|
||||||
|
raise not_found()
|
||||||
|
return item
|
||||||
|
|
||||||
|
@app.put("/monitors/{monitor_id}", response_model=Monitor)
|
||||||
|
async def update_monitor(monitor_id: UUID, value: MonitorUpdate, state: MonitorStore = Depends(get_store)) -> Monitor:
|
||||||
|
item = await state.update(monitor_id, value)
|
||||||
|
if item is None:
|
||||||
|
raise not_found()
|
||||||
|
return item
|
||||||
|
|
||||||
|
@app.delete("/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_monitor(monitor_id: UUID, state: MonitorStore = Depends(get_store)) -> Response:
|
||||||
|
if not await state.delete(monitor_id):
|
||||||
|
raise not_found()
|
||||||
|
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("/monitors/{monitor_id}/status", response_model=CurrentStatus | None)
|
||||||
|
async def current_status(monitor_id: UUID, state: MonitorStore = Depends(get_store)) -> CurrentStatus | None:
|
||||||
|
item = await state.get(monitor_id)
|
||||||
|
if item is None:
|
||||||
|
raise not_found()
|
||||||
|
return item.current_status
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
Store = Annotated[MonitorStore, Depends(store)]
|
app = create_app()
|
||||||
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]:
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/readyz")
|
|
||||||
async def readiness() -> dict[str, str]:
|
|
||||||
if not getattr(app.state, "ready", False):
|
|
||||||
raise HTTPException(status_code=503, detail="not ready")
|
|
||||||
return {"status": "ready"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED)
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
@app.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
|
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/v1/monitors/{monitor_id}", response_model=Monitor)
|
|
||||||
async def update_monitor(monitor_id: UUID, payload: MonitorUpdate, state: Store) -> Monitor:
|
|
||||||
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:
|
|
||||||
raise missing()
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
@app.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)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/monitors/{monitor_id}/status", response_model=CurrentStatus)
|
|
||||||
async def current_status(monitor_id: UUID, state: Store) -> CurrentStatus:
|
|
||||||
item = await state.get(monitor_id)
|
|
||||||
if item is None:
|
|
||||||
raise missing()
|
|
||||||
return item.current_status
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v1/monitors/{monitor_id}/check", response_model=CurrentStatus)
|
|
||||||
async def check_monitor(monitor_id: UUID, state: Store, worker: Checker) -> CurrentStatus:
|
|
||||||
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user