feat(proxy): implement proxy code validation and error handling
Build and Push to Registry / build (push) Successful in 8s
Build and Push to Registry / build (push) Successful in 8s
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user