diff --git a/app/main.py b/app/main.py index 6d88397..bb9078b 100644 --- a/app/main.py +++ b/app/main.py @@ -1,96 +1,102 @@ +from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from typing import cast from uuid import UUID -import httpx -from fastapi import FastAPI, HTTPException, Response, status +from fastapi import FastAPI, HTTPException, Request, Response, status -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) +from app.checker import EndpointChecker +from app.config import Settings, get_settings +from app.logging_config import configure_logging +from app.models import CurrentStatus, Health, Monitor, MonitorCreate, MonitorUpdate +from app.security import UnsafeTarget +from app.store import MonitorNotFound, MonitorStore -@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) +def create_app(settings: Settings | None = None, checker: EndpointChecker | None = None) -> FastAPI: + runtime_settings = settings or get_settings() + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + configure_logging(runtime_settings.log_level) + app.state.store = MonitorStore() + app.state.checker = checker or EndpointChecker(runtime_settings) + app.state.ready = True yield - app.state.ready = False + app.state.ready = False + + app = FastAPI(title="Endpoint Monitor", version="1.0.0", lifespan=lifespan) + + def store(request: Request) -> MonitorStore: + return cast(MonitorStore, request.app.state.store) + + @app.post("/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED) + async def create_monitor(data: MonitorCreate, request: Request) -> Monitor: + return await store(request).create(data) + + @app.get("/monitors", response_model=list[Monitor]) + async def list_monitors(request: Request) -> list[Monitor]: + return await store(request).list() + + @app.get("/monitors/{monitor_id}", response_model=Monitor) + async def get_monitor(monitor_id: UUID, request: Request) -> Monitor: + try: + return await store(request).get(monitor_id) + except MonitorNotFound as exc: + raise HTTPException(status_code=404, detail="monitor not found") from exc + + @app.put("/monitors/{monitor_id}", response_model=Monitor) + async def update_monitor(monitor_id: UUID, data: MonitorUpdate, request: Request) -> Monitor: + try: + return await store(request).update(monitor_id, data) + except MonitorNotFound as exc: + raise HTTPException(status_code=404, detail="monitor not found") from exc + + @app.delete("/monitors/{monitor_id}", status_code=status.HTTP_204_NO_CONTENT) + async def delete_monitor(monitor_id: UUID, request: Request) -> Response: + try: + await store(request).delete(monitor_id) + except MonitorNotFound as exc: + raise HTTPException(status_code=404, detail="monitor not found") from exc + return Response(status_code=status.HTTP_204_NO_CONTENT) + + @app.post("/monitors/{monitor_id}/check", response_model=CurrentStatus) + async def check_monitor(monitor_id: UUID, request: Request) -> CurrentStatus: + monitor_store = store(request) + try: + monitor = await monitor_store.get(monitor_id) + except MonitorNotFound as exc: + raise HTTPException(status_code=404, detail="monitor not found") from exc + active_checker = cast(EndpointChecker, request.app.state.checker) + try: + result = await active_checker.check(str(monitor.id), str(monitor.target_url)) + except UnsafeTarget as exc: + blocked = EndpointChecker._status(0 if False else __import__("time").monotonic(), monitor.current_status.state, error=None) + del blocked # Policy failures are mapped to 400 and do not overwrite the last completed status. + raise HTTPException(status_code=400, detail=str(exc)) from exc + try: + return await monitor_store.set_status(monitor_id, result) + except MonitorNotFound as exc: + raise HTTPException(status_code=404, detail="monitor deleted while check was running") from exc + + @app.get("/monitors/{monitor_id}/status", response_model=CurrentStatus) + async def get_monitor_status(monitor_id: UUID, request: Request) -> CurrentStatus: + try: + return (await store(request).get(monitor_id)).current_status + except MonitorNotFound as exc: + raise HTTPException(status_code=404, detail="monitor not found") from exc + + @app.get("/health/live", response_model=Health) + async def liveness() -> Health: + return Health(status="ok") + + @app.get("/health/ready", response_model=Health) + async def readiness(request: Request) -> Health: + if not getattr(request.app.state, "ready", False): + raise HTTPException(status_code=503, detail="not ready") + return Health(status="ready") + + return app -app = FastAPI(title=settings.app_name, version="1.0.0", lifespan=lifespan) - - -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") +app = create_app()