From e7e78a9f50ffdfd2734f12453425f7affa4e989f Mon Sep 17 00:00:00 2001 From: demo-bot Date: Sun, 9 Aug 2026 16:03:49 +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. --- app/main.py | 179 +++++++++++++++++++++++++--------------------------- 1 file changed, 85 insertions(+), 94 deletions(-) diff --git a/app/main.py b/app/main.py index 46af8ed..6d88397 100644 --- a/app/main.py +++ b/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}) +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.store = MonitorStore() + app.state.ready = True + async with httpx.AsyncClient() as client: + app.state.checker = EndpointChecker(client, settings.request_timeout_seconds, settings.max_redirects, settings.max_response_bytes) + yield + app.state.ready = False -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 with httpx.AsyncClient() as client: - application.state.checker = EndpointChecker(client, config) - yield - - application = FastAPI(title=config.app_name, version="1.0.0", lifespan=lifespan) - - def store(request: Request) -> MonitorStore: - return cast(MonitorStore, request.app.state.store) - - 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) - - @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 - - @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) - if item is None: - raise error(404, "monitor_not_found", "Monitor not found") - 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 = FastAPI(title=settings.app_name, version="1.0.0", lifespan=lifespan) -app = create_app() +def missing() -> HTTPException: + return HTTPException(status_code=404, detail="monitor not found") + + +@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) + + +@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 missing() + return item + + +@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.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")