diff --git a/app/main.py b/app/main.py index 3f30a21..dda7100 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,117 @@ -from app.api import create_app +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from typing import Annotated, AsyncIterator +from uuid import UUID -app = create_app() +from fastapi import Depends, FastAPI, HTTPException, Response, status + +from app.checker import EndpointChecker +from app.config import get_settings +from app.logging import configure_logging +from app.models import CurrentStatus, Monitor, MonitorCreate, MonitorUpdate, State +from app.security import DestinationRejected +from app.store import MonitorStore + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + settings = get_settings() + configure_logging(settings.log_level) + app.state.store = MonitorStore() + app.state.checker = EndpointChecker(settings) + app.state.ready = True + yield + app.state.ready = False + + +app = FastAPI(title="Endpoint Monitor", version="0.1.0", lifespan=lifespan) + + +def store() -> MonitorStore: + return app.state.store # type: ignore[no-any-return] + + +def checker() -> EndpointChecker: + return app.state.checker # type: ignore[no-any-return] + + +Store = Annotated[MonitorStore, Depends(store)] +Checker = Annotated[EndpointChecker, Depends(checker)] + + +def missing() -> HTTPException: + return HTTPException(status_code=404, detail="monitor not found") + + +@app.get("/healthz") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.get("/readyz") +async def readiness() -> dict[str, str]: + if not getattr(app.state, "ready", False): + raise HTTPException(status_code=503, detail="not ready") + return {"status": "ready"} + + +@app.post("/v1/monitors", response_model=Monitor, status_code=status.HTTP_201_CREATED) +async def create_monitor(payload: MonitorCreate, state: Store) -> Monitor: + return await state.create(payload) + + +@app.get("/v1/monitors", response_model=list[Monitor]) +async def list_monitors(state: Store) -> list[Monitor]: + return await state.list() + + +@app.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.patch("/v1/monitors/{monitor_id}", response_model=Monitor) +async def update_monitor(monitor_id: UUID, payload: MonitorUpdate, state: Store) -> Monitor: + if not payload.model_fields_set: + raise HTTPException(status_code=422, detail="at least one field is required") + item = await state.update(monitor_id, payload) + if item is None: + raise missing() + return item + + +@app.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) + + +@app.get("/v1/monitors/{monitor_id}/status", response_model=CurrentStatus) +async def current_status(monitor_id: UUID, state: Store) -> CurrentStatus: + item = await state.get(monitor_id) + if item is None: + raise missing() + return item.current_status + + +@app.post("/v1/monitors/{monitor_id}/check", response_model=CurrentStatus) +async def check_monitor(monitor_id: UUID, state: Store, worker: Checker) -> CurrentStatus: + item = await state.get(monitor_id) + if item is None: + raise missing() + try: + result = await worker.check(str(monitor_id), str(item.url)) + except DestinationRejected as exc: + result = CurrentStatus( + state=State.ERROR, checked_at=datetime.now(UTC), error=str(exc)[:200] + ) + if await state.set_status(monitor_id, result) is None: + raise missing() from exc + raise HTTPException(status_code=400, detail=str(exc)) from exc + if await state.set_status(monitor_id, result) is None: + raise missing() + return result