diff --git a/app/main.py b/app/main.py index 320e37e..1abc174 100644 --- a/app/main.py +++ b/app/main.py @@ -1,101 +1,107 @@ +import logging from contextlib import asynccontextmanager -from typing import AsyncIterator from uuid import UUID -from fastapi import Depends, FastAPI, HTTPException, Request, Response, status +from fastapi import FastAPI, HTTPException, Response, status -from app.checker import EndpointChecker -from app.config import Settings, get_settings -from app.logging import configure_logging -from app.models import CheckResponse, CurrentStatus, Monitor, MonitorCreate, MonitorUpdate -from app.store import MonitorStore +from .checker import check_url +from .config import get_settings +from .logging import log_event +from .models import CheckResult, CheckSnapshot, Monitor, MonitorInput +from .security import UnsafeDestination +from .store import MonitorStore, StoreFullError + +settings = get_settings() +logging.basicConfig(level=settings.log_level, format="%(message)s") +logger = logging.getLogger("endpoint_monitor.api") -def not_found() -> HTTPException: - return HTTPException(status_code=404, detail={"code": "monitor_not_found", "message": "monitor does not exist"}) +@asynccontextmanager +async def lifespan(app: FastAPI): # type: ignore[no-untyped-def] + app.state.store = MonitorStore(settings.max_monitors) + log_event(logger, "service_started", storage="process-local-memory") + yield -def get_store(request: Request) -> MonitorStore: - return request.app.state.store +app = FastAPI(title="Endpoint Monitor", version="0.1.0", lifespan=lifespan) -def get_checker(request: Request) -> EndpointChecker: - return request.app.state.checker +def store() -> MonitorStore: + return app.state.store -def create_app( - 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 +def missing() -> HTTPException: + return HTTPException(status_code=404, detail="monitor not found") -app = create_app() +@app.get("/healthz") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.get("/readyz") +async def ready() -> dict[str, str]: + return {"status": "ready", "storage": "process-local-memory"} + + +@app.post("/monitors", response_model=Monitor, status_code=201) +async def create_monitor(data: MonitorInput) -> Monitor: + try: + return await store().create(data) + except StoreFullError as exc: + raise HTTPException(status_code=503, detail="monitor capacity reached") from exc + + +@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.put("/monitors/{monitor_id}", response_model=Monitor) +async def replace_monitor(monitor_id: UUID, data: MonitorInput) -> Monitor: + item = await store().replace(monitor_id, data) + if item is None: + raise missing() + return item + + +@app.delete("/monitors/{monitor_id}", status_code=204) +async def delete_monitor(monitor_id: UUID) -> Response: + if not await store().delete(monitor_id): + raise missing() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@app.get("/monitors/{monitor_id}/status", response_model=CheckSnapshot) +async def current_status(monitor_id: UUID) -> CheckSnapshot: + 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 check_monitor(monitor_id: UUID) -> CheckResult: + item = await store().get(monitor_id) + if item is None: + raise missing() + timeout = item.timeout_seconds or settings.request_timeout_seconds + try: + snapshot = await check_url(str(item.url), timeout, settings) + except UnsafeDestination as exc: + snapshot = CheckSnapshot(state="error", checked_at=__import__( + "datetime").datetime.now(__import__("datetime").UTC), error=str(exc)) + await store().set_status(item.id, item.revision, snapshot) + raise HTTPException(status_code=400, detail=str(exc)) from exc + updated = await store().set_status(item.id, item.revision, snapshot) + if updated is None: + raise HTTPException(status_code=409, detail="monitor changed during check") + return CheckResult(monitor_id=item.id, **snapshot.model_dump())