diff --git a/src/endpoint_monitor/store.py b/src/endpoint_monitor/store.py new file mode 100644 index 0000000..b3efa63 --- /dev/null +++ b/src/endpoint_monitor/store.py @@ -0,0 +1,80 @@ +import asyncio +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from .models import CheckResult, Monitor, MonitorCreate, MonitorStatus, MonitorUpdate + + +class StoreError(Exception): + pass + + +class NotFound(StoreError): + pass + + +class Conflict(StoreError): + pass + + +class MonitorStore: + def __init__(self, max_monitors: int) -> None: + self._items: dict[UUID, Monitor] = {} + self._lock = asyncio.Lock() + self._max = max_monitors + + async def create(self, data: MonitorCreate) -> Monitor: + async with self._lock: + if len(self._items) >= self._max: + raise Conflict("monitor capacity reached") + if any(item.name == data.name for item in self._items.values()): + raise Conflict("monitor name already exists") + now = datetime.now(UTC) + item = Monitor(id=uuid4(), name=data.name, url=data.url, created_at=now, updated_at=now, revision=1) + self._items[item.id] = item + return item.model_copy(deep=True) + + async def list(self) -> list[Monitor]: + async with self._lock: + return [item.model_copy(deep=True) for item in self._items.values()] + + async def get(self, monitor_id: UUID) -> Monitor: + async with self._lock: + item = self._items.get(monitor_id) + if item is None: + raise NotFound("monitor not found") + return item.model_copy(deep=True) + + async def update(self, monitor_id: UUID, data: MonitorUpdate) -> Monitor: + async with self._lock: + item = self._items.get(monitor_id) + if item is None: + raise NotFound("monitor not found") + changes = data.model_dump(exclude_unset=True) + if "name" in changes and changes["name"] is None: + changes.pop("name") + if "url" in changes and changes["url"] is None: + changes.pop("url") + name = changes.get("name") + if name and any(other.id != monitor_id and other.name == name for other in self._items.values()): + raise Conflict("monitor name already exists") + if "url" in changes and changes["url"] != item.url: + changes.update(status=MonitorStatus.UNKNOWN, last_check=None) + changes.update(updated_at=datetime.now(UTC), revision=item.revision + 1) + item = item.model_copy(update=changes) + self._items[monitor_id] = item + return item.model_copy(deep=True) + + async def delete(self, monitor_id: UUID) -> None: + async with self._lock: + if self._items.pop(monitor_id, None) is None: + raise NotFound("monitor not found") + + async def record_check(self, monitor_id: UUID, result: CheckResult) -> Monitor: + async with self._lock: + item = self._items.get(monitor_id) + if item is None: + raise NotFound("monitor not found") + item = item.model_copy(update={"status": result.status, "last_check": result, "updated_at": datetime.now(UTC), "revision": item.revision + 1}) + self._items[monitor_id] = item + return item.model_copy(deep=True)