feat(proxy): implement proxy code validation and error handling
Build and Push to Registry / build (push) Successful in 8s

This commit is contained in:
gabdevele
2026-04-12 21:37:10 +02:00 Unverified
parent 405a465c72
commit 84fada7bf2
3 changed files with 300 additions and 2 deletions
+16
View File
@@ -10,6 +10,7 @@ from db.proxy import (
) )
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from models.proxy import ProxyCreateInput, ProxyUpdateInput from models.proxy import ProxyCreateInput, ProxyUpdateInput
from utils.proxy.validator import ProxyCodeValidationError, validate_proxy_code
router = APIRouter( router = APIRouter(
prefix="/proxy", prefix="/proxy",
@@ -18,6 +19,16 @@ router = APIRouter(
) )
async def _validate_code_or_raise(code: str) -> None:
try:
await validate_proxy_code(code)
except ProxyCodeValidationError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=error.to_http_detail(),
) from error
@router.get("") @router.get("")
async def list_proxies_handler(): async def list_proxies_handler():
return await list_proxy_documents() return await list_proxy_documents()
@@ -43,6 +54,8 @@ async def create_proxy_handler(payload: ProxyCreateInput):
detail=f"Proxy already exists on port {payload.proxy_port}", detail=f"Proxy already exists on port {payload.proxy_port}",
) )
await _validate_code_or_raise(payload.code)
return await create_proxy( return await create_proxy(
proxy_port=payload.proxy_port, proxy_port=payload.proxy_port,
name=payload.name, name=payload.name,
@@ -53,6 +66,9 @@ async def create_proxy_handler(payload: ProxyCreateInput):
@router.put("/{proxy_port}") @router.put("/{proxy_port}")
async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput): async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput):
if payload.code is not None:
await _validate_code_or_raise(payload.code)
updated_proxy = await update_proxy(proxy_port, payload) updated_proxy = await update_proxy(proxy_port, payload)
if updated_proxy is None: if updated_proxy is None:
raise HTTPException( raise HTTPException(
+164
View File
@@ -0,0 +1,164 @@
import asyncio
import json
import os
import shutil
import tempfile
from typing import Any
TARGET_RUFF_CODES = ("F821", "F822", "F823", "E999")
class ProxyCodeValidationError(Exception):
def __init__(
self,
message: str,
*,
errors: list[dict[str, Any]] | None = None,
) -> None:
super().__init__(message)
self.message = message
self.errors = errors or []
def to_http_detail(self) -> dict[str, Any]:
return {
"message": self.message,
"errors": self.errors,
}
def _validate_python_syntax(code: str) -> None:
try:
compile(code, "<proxy_code>", "exec")
except SyntaxError as error:
line = error.lineno if error.lineno is not None else 0
column = error.offset if error.offset is not None else 0
detail = {
"code": "COMPILE",
"message": error.msg,
"location": {"row": line, "column": column},
}
raise ProxyCodeValidationError(
"Proxy code has invalid Python syntax",
errors=[detail],
) from error
def _get_ruff_executable() -> list[str]:
ruff_path = shutil.which("ruff")
if ruff_path:
return [ruff_path]
uv_path = shutil.which("uv")
if uv_path:
return [uv_path, "run", "ruff"]
raise ProxyCodeValidationError("Unable to run ruff: executable not found")
def _parse_ruff_output(output: str) -> list[dict[str, Any]]:
if not output.strip():
return []
try:
parsed = json.loads(output)
except json.JSONDecodeError as error:
raise ProxyCodeValidationError("Unable to parse ruff JSON output") from error
if not isinstance(parsed, list):
raise ProxyCodeValidationError("Unexpected ruff output format")
errors: list[dict[str, Any]] = []
for item in parsed:
if not isinstance(item, dict):
continue
code = item.get("code")
if code not in TARGET_RUFF_CODES:
continue
message = item.get("message")
raw_location = item.get("location")
location = raw_location if isinstance(raw_location, dict) else {}
row = location.get("row", 0)
column = location.get("column", 0)
errors.append(
{
"code": code,
"message": str(message) if message is not None else "",
"location": {"row": row, "column": column},
}
)
return errors
async def _run_ruff_check(code: str) -> list[dict[str, Any]]:
command = _get_ruff_executable()
file_path = ""
try:
with tempfile.NamedTemporaryFile(
mode="w",
suffix=".py",
encoding="utf-8",
delete=False,
) as handle:
handle.write(code)
file_path = handle.name
process = await asyncio.create_subprocess_exec(
*command,
"check",
"--output-format=json",
"--select",
",".join(TARGET_RUFF_CODES),
file_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
finally:
if file_path:
try:
os.unlink(file_path)
except FileNotFoundError:
pass
stdout_text = stdout.decode("utf-8", errors="replace")
stderr_text = stderr.decode("utf-8", errors="replace")
if process.returncode not in (0, 1):
raise ProxyCodeValidationError(
"Ruff check failed to execute",
errors=[
{
"code": "RUFF",
"message": stderr_text.strip() or "Unknown ruff failure",
"location": {"row": 0, "column": 0},
}
],
)
if stderr_text.strip() and process.returncode == 0:
raise ProxyCodeValidationError(
"Ruff emitted unexpected stderr output",
errors=[
{
"code": "RUFF",
"message": stderr_text.strip(),
"location": {"row": 0, "column": 0},
}
],
)
return _parse_ruff_output(stdout_text)
async def validate_proxy_code(code: str) -> None:
_validate_python_syntax(code)
errors = await _run_ruff_check(code)
if errors:
raise ProxyCodeValidationError(
"Proxy code contains ruff violations",
errors=errors,
)
+120 -2
View File
@@ -3,6 +3,7 @@ import importlib
from core.security import get_auth_context, require_authenticated_user from core.security import get_auth_context, require_authenticated_user
from models.proxy import ProxyDocument from models.proxy import ProxyDocument
from models.service import ServiceEndpointDocument from models.service import ServiceEndpointDocument
from utils.proxy.validator import ProxyCodeValidationError
proxy_router_module = importlib.import_module("api.proxy.router") proxy_router_module = importlib.import_module("api.proxy.router")
@@ -120,11 +121,19 @@ def test_create_proxy_returns_created_document(client, monkeypatch: object):
port=int(service["port"]), port=int(service["port"]),
) )
async def fake_validate_proxy_code(code: str) -> None:
assert code == "def proxy_handler():\n return 'ok'\n"
monkeypatch.setattr( monkeypatch.setattr(
proxy_router_module, proxy_router_module,
"get_proxy_by_port", "get_proxy_by_port",
fake_get_proxy_by_port, fake_get_proxy_by_port,
) )
monkeypatch.setattr(
proxy_router_module,
"validate_proxy_code",
fake_validate_proxy_code,
)
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy) monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
response = client.post( response = client.post(
@@ -132,7 +141,7 @@ def test_create_proxy_returns_created_document(client, monkeypatch: object):
json={ json={
"proxy_port": 4040, "proxy_port": 4040,
"name": "edge", "name": "edge",
"code": "edge-code", "code": "def proxy_handler():\n return 'ok'\n",
"service": {"address": "service.local", "port": 8181}, "service": {"address": "service.local", "port": 8181},
}, },
) )
@@ -143,5 +152,114 @@ def test_create_proxy_returns_created_document(client, monkeypatch: object):
"proxy_port": 4040, "proxy_port": 4040,
"name": "edge", "name": "edge",
"service": {"address": "service.local", "port": 8181}, "service": {"address": "service.local", "port": 8181},
"code": "edge-code", "code": "def proxy_handler():\n return 'ok'\n",
} }
def test_create_proxy_returns_422_when_code_validation_fails(
client,
monkeypatch: object,
):
create_called = False
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
assert proxy_port == 4000
return None
async def fake_validate_proxy_code(code: str) -> None:
assert code == "invalid-code"
raise ProxyCodeValidationError(
"Proxy code contains ruff violations",
errors=[
{
"code": "F821",
"message": "Undefined name `missing_symbol`",
"location": {"row": 1, "column": 1},
}
],
)
async def fake_create_proxy(
*,
proxy_port: int,
name: str,
service: dict[str, object],
code: str,
) -> ProxyDocument:
nonlocal create_called
create_called = True
return make_proxy_document(proxy_port=proxy_port, name=name, code=code)
monkeypatch.setattr(
proxy_router_module,
"get_proxy_by_port",
fake_get_proxy_by_port,
)
monkeypatch.setattr(
proxy_router_module,
"validate_proxy_code",
fake_validate_proxy_code,
)
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
response = client.post(
"/proxy",
json={
"proxy_port": 4000,
"name": "broken",
"code": "invalid-code",
"service": {"address": "service.local", "port": 9000},
},
)
assert response.status_code == 422
body = response.json()["detail"]
assert body["message"] == "Proxy code contains ruff violations"
assert body["errors"][0]["code"] == "F821"
assert create_called is False
def test_update_proxy_returns_422_when_code_validation_fails(
client,
monkeypatch: object,
):
update_called = False
async def fake_validate_proxy_code(code: str) -> None:
assert code == "invalid-code"
raise ProxyCodeValidationError(
"Proxy code has invalid Python syntax",
errors=[
{
"code": "COMPILE",
"message": "invalid syntax",
"location": {"row": 1, "column": 1},
}
],
)
async def fake_update_proxy(
proxy_port: int,
payload: object,
) -> ProxyDocument | None:
nonlocal update_called
update_called = True
return make_proxy_document(proxy_port=proxy_port)
monkeypatch.setattr(
proxy_router_module,
"validate_proxy_code",
fake_validate_proxy_code,
)
monkeypatch.setattr(proxy_router_module, "update_proxy", fake_update_proxy)
response = client.put(
"/proxy/8123",
json={"code": "invalid-code"},
)
assert response.status_code == 422
body = response.json()["detail"]
assert body["message"] == "Proxy code has invalid Python syntax"
assert body["errors"][0]["code"] == "COMPILE"
assert update_called is False