diff --git a/app/main.py b/app/main.py index dda7100..320e37e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,117 +1,101 @@ from contextlib import asynccontextmanager -from datetime import UTC, datetime -from typing import Annotated, AsyncIterator +from typing import AsyncIterator from uuid import UUID -from fastapi import Depends, FastAPI, HTTPException, Response, status +from fastapi import Depends, FastAPI, HTTPException, Request, Response, status from app.checker import EndpointChecker -from app.config import get_settings +from app.config import Settings, get_settings from app.logging import configure_logging -from app.models import CurrentStatus, Monitor, MonitorCreate, MonitorUpdate, State -from app.security import DestinationRejected +from app.models import CheckResponse, CurrentStatus, Monitor, MonitorCreate, MonitorUpdate 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 +def not_found() -> HTTPException: + return HTTPException(status_code=404, detail={"code": "monitor_not_found", "message": "monitor does not exist"}) -app = FastAPI(title="Endpoint Monitor", version="0.1.0", lifespan=lifespan) +def get_store(request: Request) -> MonitorStore: + return request.app.state.store -def store() -> MonitorStore: - return app.state.store # type: ignore[no-any-return] +def get_checker(request: Request) -> EndpointChecker: + return request.app.state.checker -def checker() -> EndpointChecker: - return app.state.checker # type: ignore[no-any-return] +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 -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 +app = create_app()