From 7cf97cbe6d71996d6fcd7a541bdbfb11ea08ed0a Mon Sep 17 00:00:00 2001 From: demo-bot Date: Sun, 9 Aug 2026 15:44:06 +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/api.py | 177 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 105 insertions(+), 72 deletions(-) diff --git a/app/api.py b/app/api.py index 4cc7cb0..d1b3bee 100644 --- a/app/api.py +++ b/app/api.py @@ -1,92 +1,125 @@ -from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi import FastAPI, HTTPException, Request, Response, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse from app.checker import EndpointChecker -from app.models import CurrentStatus, Monitor, MonitorCreate, MonitorUpdate -from app.store import MonitorStore +from app.config import Settings, get_settings +from app.logging import configure_logging +from app.models import CheckResult, ErrorResponse, Monitor, MonitorCreate, MonitorUpdate +from app.store import MonitorNotFoundError, MonitorStore -router = APIRouter() +ERROR_RESPONSES = { + 404: {"model": ErrorResponse, "description": "Monitor not found"}, + 422: {"model": ErrorResponse, "description": "Invalid request"}, +} -def store(request: Request) -> MonitorStore: - return request.app.state.store +def create_app( + settings: Settings | None = None, + store: MonitorStore | None = None, + checker: EndpointChecker | None = None, +) -> FastAPI: + settings = settings or get_settings() + configure_logging(settings.log_level) + app = FastAPI(title="Endpoint Monitor Service", version="0.1.0") + app.state.store = store or MonitorStore() + app.state.checker = checker or EndpointChecker( + settings.check_timeout_seconds, settings.max_redirects + ) + @app.exception_handler(HTTPException) + async def http_error(_: Request, exc: HTTPException) -> JSONResponse: + code = "not_found" if exc.status_code == 404 else "http_error" + return JSONResponse( + status_code=exc.status_code, + content={"error": {"code": code, "message": str(exc.detail)}}, + ) -def checker(request: Request) -> EndpointChecker: - return request.app.state.checker + @app.exception_handler(RequestValidationError) + async def validation_error(_: Request, __: RequestValidationError) -> JSONResponse: + return JSONResponse( + status_code=422, + content={"error": {"code": "invalid_request", "message": "request validation failed"}}, + ) + def missing() -> HTTPException: + return HTTPException(status_code=404, detail="monitor not found") -Store = Annotated[MonitorStore, Depends(store)] -Checker = Annotated[EndpointChecker, Depends(checker)] + @app.get("/health/live", tags=["operations"]) + async def live() -> dict[str, str]: + return {"status": "ok"} + @app.get("/health/ready", tags=["operations"]) + async def ready() -> dict[str, str]: + return {"status": "ready", "storage": "process-local-memory"} -def missing() -> HTTPException: - return HTTPException(status_code=404, detail=("monitor_not_found", "Monitor not found")) + @app.post( + "/monitors", + response_model=Monitor, + status_code=status.HTTP_201_CREATED, + responses={422: ERROR_RESPONSES[422]}, + ) + async def create_monitor(data: MonitorCreate) -> Monitor: + return await app.state.store.create(data) + @app.get("/monitors", response_model=list[Monitor]) + async def list_monitors() -> list[Monitor]: + return await app.state.store.list() -@router.post("/v1/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED) -async def create_monitor(data: MonitorCreate, state: Store) -> Monitor: - return await state.create(data) + @app.get("/monitors/{monitor_id}", response_model=Monitor, responses=ERROR_RESPONSES) + async def get_monitor(monitor_id: UUID) -> Monitor: + try: + return await app.state.store.get(monitor_id) + except MonitorNotFoundError as exc: + raise missing() from exc + @app.patch("/monitors/{monitor_id}", response_model=Monitor, responses=ERROR_RESPONSES) + async def update_monitor(monitor_id: UUID, data: MonitorUpdate) -> Monitor: + try: + return await app.state.store.update(monitor_id, data) + except MonitorNotFoundError as exc: + raise missing() from exc -@router.get("/v1/monitors", response_model=list[Monitor]) -async def list_monitors(state: Store) -> list[Monitor]: - return await state.list() + @app.delete( + "/monitors/{monitor_id}", + status_code=status.HTTP_204_NO_CONTENT, + responses={404: ERROR_RESPONSES[404]}, + ) + async def delete_monitor(monitor_id: UUID) -> Response: + try: + await app.state.store.delete(monitor_id) + except MonitorNotFoundError as exc: + raise missing() from exc + return Response(status_code=status.HTTP_204_NO_CONTENT) + @app.get( + "/monitors/{monitor_id}/status", + response_model=CheckResult, + responses=ERROR_RESPONSES, + ) + async def current_status(monitor_id: UUID) -> CheckResult: + try: + monitor = await app.state.store.get(monitor_id) + except MonitorNotFoundError as exc: + raise missing() from exc + return CheckResult(monitor_id=monitor.id, **monitor.current_status.model_dump()) -@router.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.post( + "/monitors/{monitor_id}/check", + response_model=CheckResult, + responses=ERROR_RESPONSES, + ) + async def check_monitor(monitor_id: UUID) -> CheckResult: + try: + monitor = await app.state.store.get(monitor_id) + result = await app.state.checker.check(monitor) + await app.state.store.set_status( + monitor_id, result.model_copy(update={}, deep=True) + ) + return result + except MonitorNotFoundError as exc: + raise missing() from exc - -@router.patch("/v1/monitors/{monitor_id}", response_model=Monitor) -async def update_monitor(monitor_id: UUID, data: MonitorUpdate, state: Store) -> Monitor: - item = await state.update(monitor_id, data) - if item is None: - raise missing() - return item - - -@router.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) - - -@router.post("/v1/monitors/{monitor_id}/check", response_model=CurrentStatus) -async def check_monitor(monitor_id: UUID, state: Store, outbound: Checker) -> CurrentStatus: - snapshot = await state.snapshot(monitor_id) - if snapshot is None: - raise missing() - item, revision = snapshot - result = await outbound.check(str(item.url)) - if await state.record_status(monitor_id, revision, result) is None: - raise HTTPException(status_code=409, - detail=("monitor_changed", "Monitor changed while check was running")) - return result - - -@router.get("/v1/monitors/{monitor_id}/status", response_model=CurrentStatus) -async def get_status(monitor_id: UUID, state: Store) -> CurrentStatus: - item = await state.get(monitor_id) - if item is None: - raise missing() - return item.current_status - - -@router.get("/healthz") -async def health() -> dict[str, str]: - return {"status": "ok"} - - -@router.get("/readyz") -async def readiness(request: Request) -> dict[str, str]: - if not hasattr(request.app.state, "store"): - raise HTTPException(status_code=503, detail=("not_ready", "Service is not ready")) - return {"status": "ready", "storage": "process-local-memory"} + return app