From 9a5e44f474b3c7498b368fc5025789b9c29c1ce1 Mon Sep 17 00:00:00 2001 From: demo-bot Date: Sun, 9 Aug 2026 16:00:58 +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 | 138 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 82 insertions(+), 56 deletions(-) diff --git a/app/main.py b/app/main.py index e38f135..46af8ed 100644 --- a/app/main.py +++ b/app/main.py @@ -1,79 +1,105 @@ +from contextlib import asynccontextmanager +from typing import AsyncIterator, cast from uuid import UUID -from fastapi import FastAPI, HTTPException, Response, status +import httpx +from fastapi import Depends, FastAPI, HTTPException, Request, Response, status -from app.checker import EndpointChecker +from app.checker import EndpointChecker, UnsafeTarget from app.config import Settings, get_settings -from app.logging_config import configure_logging -from app.models import Health, Monitor, MonitorCreate, MonitorUpdate, StatusSnapshot -from app.store import MonitorStore +from app.logging import configure_logging +from app.models import (CheckResult, CheckStatus, HealthResponse, Monitor, + MonitorCreate, MonitorUpdate) +from app.store import MonitorStore, Snapshot -def create_app( - settings: Settings | None = None, store: MonitorStore | None = None, - checker: EndpointChecker | None = None, -) -> FastAPI: +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) - state = store or MonitorStore() - service = checker or EndpointChecker(config) - api = FastAPI(title=config.app_name, version="1.0.0") - async def existing(monitor_id: UUID) -> Monitor: - monitor = await state.get(monitor_id) - if monitor is None: - raise HTTPException(status_code=404, detail="monitor not found") - return monitor + @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 - @api.post("/monitors", response_model=Monitor, status_code=201) - async def create_monitor(body: MonitorCreate) -> Monitor: - return await state.create(body) + application = FastAPI(title=config.app_name, version="1.0.0", lifespan=lifespan) - @api.get("/monitors", response_model=list[Monitor]) - async def list_monitors() -> list[Monitor]: - return await state.list() + def store(request: Request) -> MonitorStore: + return cast(MonitorStore, request.app.state.store) - @api.get("/monitors/{monitor_id}", response_model=Monitor) - async def get_monitor(monitor_id: UUID) -> Monitor: - return await existing(monitor_id) + 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 - @api.put("/monitors/{monitor_id}", response_model=Monitor) - async def update_monitor(monitor_id: UUID, body: MonitorUpdate) -> Monitor: - monitor = await state.update(monitor_id, body) - if monitor is None: - raise HTTPException(status_code=404, detail="monitor not found") - return monitor + @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) - @api.delete("/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT) - async def delete_monitor(monitor_id: UUID) -> Response: - if not await state.delete(monitor_id): - raise 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 + + @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) - @api.post("/monitors/{monitor_id}/check", response_model=StatusSnapshot) - async def check_monitor(monitor_id: UUID) -> StatusSnapshot: - pending = await state.begin_check(monitor_id) - if pending is None: - raise HTTPException(status_code=404, detail="monitor not found") - monitor, revision = pending - result = await service.check(str(monitor.id), str(monitor.url)) - await state.finish_check(monitor_id, revision, result) - return result + @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()) - @api.get("/monitors/{monitor_id}/status", response_model=StatusSnapshot) - async def current_status(monitor_id: UUID) -> StatusSnapshot: - return (await existing(monitor_id)).status + @application.get("/v1/monitors/{monitor_id}/status", response_model=CheckStatus) + async def current_status(snapshot: Snapshot = Depends(existing)) -> CheckStatus: + return snapshot.monitor.status - @api.get("/health/live", response_model=Health) - async def liveness() -> Health: - return Health(status="ok") + @application.get("/healthz", response_model=HealthResponse) + async def health() -> HealthResponse: + return HealthResponse(status="ok") - @api.get("/health/ready", response_model=Health) - async def readiness() -> Health: - await state.list() - return Health(status="ready") + @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 api + return application app = create_app()