From 9a8f3f8d060a76054578069d3972becc07e857ed Mon Sep 17 00:00:00 2001 From: demo-bot Date: Sun, 9 Aug 2026 15:38:27 +0000 Subject: [PATCH] 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. --- src/monitor_service/api.py | 121 +++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/monitor_service/api.py diff --git a/src/monitor_service/api.py b/src/monitor_service/api.py new file mode 100644 index 0000000..b6b8473 --- /dev/null +++ b/src/monitor_service/api.py @@ -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()