Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ unsafe-fixes = true

[tool.ruff.lint]
select = ["ALL"]
ignore = ["INP001", "ARG001"]
ignore = ["INP001", "ARG001", "PLR0911"]

[tool.pyright]
include = ["src"]
Expand Down
10 changes: 10 additions & 0 deletions src/app/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,20 @@
from fastapi import FastAPI, status
from fastapi.staticfiles import StaticFiles
from slowapi.errors import RateLimitExceeded
from sqlalchemy.exc import IntegrityError, SQLAlchemyError

from app import api, frontend, shared
from app.api import docs
from app.container import Container
from app.monitoring.scheduler import WorkerSchedulerError
from app.shared import config
from app.shared.exc_handlers import (
general_exception_handler,
integrity_error_handler,
not_found_handler,
rate_limit_exception_handler,
sqlalchemy_error_handler,
worker_scheduler_error_handler,
)
from app.shared.log_filters import HealthCheckFilter

Expand Down Expand Up @@ -66,6 +72,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:

app.add_exception_handler(status.HTTP_404_NOT_FOUND, not_found_handler)
app.add_exception_handler(RateLimitExceeded, rate_limit_exception_handler)
app.add_exception_handler(IntegrityError, integrity_error_handler)
app.add_exception_handler(SQLAlchemyError, sqlalchemy_error_handler)
app.add_exception_handler(WorkerSchedulerError, worker_scheduler_error_handler)
app.add_exception_handler(Exception, general_exception_handler)

api.middlewares.setup_middlewares(app)
frontend.middlewares.setup_middlewares(app)
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/middlewares/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
from typing import TYPE_CHECKING, ClassVar

from fastapi import status
from fastapi.responses import JSONResponse

from app.shared import config
Expand Down Expand Up @@ -45,7 +46,7 @@ async def dispatch(
if not self._verify_token(request):
return JSONResponse(
content={"detail": "Unauthorized"},
status_code=401,
status_code=status.HTTP_401_UNAUTHORIZED,
)

return await call_next(request)
2 changes: 1 addition & 1 deletion src/app/api/models/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class MonitorRequest(BaseModel):
@model_validator(mode="before")
@classmethod
def validate(cls, values: dict) -> dict:
"""Validate HTTP endpoint."""
"""Validate monitor configuration."""
monitor_type = values.get("type")

if monitor_type == MonitorType.HTTP:
Expand Down
64 changes: 57 additions & 7 deletions src/app/api/models/validators/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

from urllib.parse import urlparse

from fastapi import HTTPException
from fastapi import HTTPException, status

HTTP_ERROR_MIN = 100
HTTP_ERROR_MAX = 599


def _validate_endpoint(endpoint: str) -> None:
Expand All @@ -11,17 +14,20 @@ def _validate_endpoint(endpoint: str) -> None:
parsed = urlparse(endpoint)

if parsed.scheme not in ["http", "https"]:
raise HTTPException(status_code=400, detail="Invalid URL scheme")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid URL scheme",
)

if not parsed.netloc:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid endpoint: missing domain",
)

except ValueError as e:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid endpoint URL format",
) from e

Expand All @@ -31,29 +37,73 @@ def _validate_method(method: str) -> None:
allowed_methods = ["GET", "POST", "PUT", "DELETE"]
if method.upper() not in allowed_methods:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Invalid HTTP method. Allowed: {', '.join(allowed_methods)}",
),
)


def _validate_codes(values: dict) -> None:
"""Validate HTTP codes."""
expected_response_code = values.get("expected_response_code")
if expected_response_code is not None and not (
HTTP_ERROR_MIN <= int(expected_response_code) <= HTTP_ERROR_MAX
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Expected response code must be between 100 and 599",
)

latency_threshold_ms = values.get("latency_threshold_ms")
if latency_threshold_ms is not None and int(latency_threshold_ms) <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Latency threshold must be greater than 0",
)

error_mapping = values.get("error_mapping")
if error_mapping:
invalid_codes = []

for code in error_mapping:
try:
code_int = int(code)

except (TypeError, ValueError):
invalid_codes.append(code)
continue

if code_int < HTTP_ERROR_MIN or code_int > HTTP_ERROR_MAX:
invalid_codes.append(code)

if invalid_codes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Error mapping keys must be HTTP status codes between"
f" 100 and 599; invalid: {invalid_codes}"
),
)


def validate_http_monitor(values: dict) -> None:
"""Validate HTTP monitor configuration."""
endpoint = values.get("endpoint")
method = values.get("method")

if not endpoint:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing endpoint for HTTP monitor",
)

if not method:
raise HTTPException(
status_code=400,
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing method for HTTP monitor",
)

_validate_endpoint(endpoint)
_validate_method(method)
_validate_codes(values)
6 changes: 3 additions & 3 deletions src/app/api/v1/admin/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,10 @@ async def logout(
) -> LogoutResponse:
"""Admin logout."""
response.delete_cookie(
key="token",
key=config.cookie.key,
httponly=True,
samesite="none" if config.app.is_production else "lax",
secure=config.app.is_production,
samesite="lax",
secure=config.app.https,
)

return LogoutResponse(
Expand Down
25 changes: 25 additions & 0 deletions src/app/api/v1/admin/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ async def create_group(
) -> MonitorsGroupResponse:
"""Create a specific group."""
async with uow_factory() as uow:
existing_group = await uow.groups.find_by_name(create_request.name)

if existing_group:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Group with this name already exists",
)

group = await uow.groups.save(
MonitorGroupModel(name=create_request.name),
)
Expand Down Expand Up @@ -178,6 +186,15 @@ async def update_group(
detail="Group not found",
)

if group.name != update_request.name:
existing_group = await uow.groups.find_by_name(update_request.name)

if existing_group:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Group with this name already exists",
)

group.name = update_request.name
group = await uow.groups.save(group)

Expand Down Expand Up @@ -222,6 +239,14 @@ async def delete_group(
detail="Group not found",
)

monitors_in_group = await uow.monitors.find_by_group_id(group_id)

if monitors_in_group:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete group with assigned monitors",
)

group.is_deleted = True
await uow.groups.save(group)

Expand Down
19 changes: 19 additions & 0 deletions src/app/api/v1/admin/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ async def create_monitor(
) -> MonitorResponse:
"""Create a new monitor."""
async with uow_factory() as uow:
existing_monitor = await uow.monitors.find_by_name(create_request.name)

if existing_monitor:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Monitor with this name already exists",
)

if create_request.group_id:
group = await uow.groups.find_by_id(create_request.group_id)
if not group:
Expand Down Expand Up @@ -225,6 +233,17 @@ async def update_monitor(
detail="Monitor not found",
)

if monitor.name != update_request.name:
existing_monitor = await uow.monitors.find_by_name(
update_request.name,
)

if existing_monitor:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Monitor with this name already exists",
)

if (
update_request.group_id
and monitor.group_id != update_request.group_id
Expand Down
3 changes: 2 additions & 1 deletion src/app/frontend/middlewares/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
from typing import TYPE_CHECKING, ClassVar

from fastapi import status
from fastapi.responses import RedirectResponse

from app.shared import config
Expand Down Expand Up @@ -50,7 +51,7 @@ async def dispatch(
if not self._verify_token(request):
return RedirectResponse(
url=f"/{config.admin.safe_path}/login",
status_code=302,
status_code=status.HTTP_302_FOUND,
)

return await call_next(request)
7 changes: 6 additions & 1 deletion src/app/monitoring/workers/http.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""HTTP worker for endpoint monitoring."""

import json
import logging

import httpx
Expand Down Expand Up @@ -97,7 +98,11 @@ async def _execute_request(self, client: httpx.AsyncClient) -> Response:

kwargs = {}
if self._config.request_body:
kwargs["json"] = self._config.request_body
try:
kwargs["json"] = json.loads(self._config.request_body)

except json.JSONDecodeError:
kwargs["data"] = self._config.request_body

response = await client.request(
method,
Expand Down
10 changes: 10 additions & 0 deletions src/app/repositories/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ async def find_by_id(
result = await self._session.execute(stmt)
return result.scalar_one_or_none()

async def find_by_name(self, name: str) -> MonitorGroupModel | None:
"""Find a group by name."""
stmt = select(MonitorGroupModel).where(
MonitorGroupModel.name == name,
MonitorGroupModel.is_deleted == False, # noqa: E712
)

result = await self._session.execute(stmt)
return result.scalar_one_or_none()

async def find_all(self) -> list[MonitorGroupModel]:
"""Find all monitors."""
result = await self._session.execute(
Expand Down
10 changes: 10 additions & 0 deletions src/app/repositories/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ async def find_by_id(
result = await self._session.execute(stmt)
return result.scalar_one_or_none()

async def find_by_name(self, name: str) -> MonitorModel | None:
"""Find a monitor by name."""
stmt = select(MonitorModel).where(
MonitorModel.name == name,
MonitorModel.is_deleted == False, # noqa: E712
)

result = await self._session.execute(stmt)
return result.scalar_one_or_none()

async def find_by_group_id(self, group_id: UUID) -> list[MonitorModel]:
"""Find monitors by group ID."""
result = await self._session.execute(
Expand Down
Loading
Loading