22474cdba5
Backend (ruff): - Fix 106 errors: move imports to top of file (E402) - Remove unused imports (F401) - Add missing imports for undefined names (F821) - Remove unused variables (F841) - Fix test_models.py broken RefreshToken test - Fix test_projects_api.py missing TestClient import Frontend (eslint): - Remove unused imports/variables across 10 files - Fix explicit any types in client.ts and sessions.ts - Clean up empty block statements in terminal.tsx Quality gates: ruff (pass), eslint (pass), tsc --noEmit (pass), pytest (98 passed, 4 pre-existing failures)
291 lines
11 KiB
Python
291 lines
11 KiB
Python
"""Tool configuration API endpoints."""
|
|
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes
|
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
|
from src.models.tool_config import ToolConfig
|
|
from src.models.tool_type import ToolType
|
|
|
|
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
|
|
|
|
|
|
class ToolConfigCreate(BaseModel):
|
|
tool_type_id: str = Field(description="UUID of the tool type")
|
|
project_id: str | None = Field(default=None, description="Optional project ID for project-scoped config")
|
|
key: str = Field(description="Config key name")
|
|
value: str = Field(description="Config value")
|
|
config_type: str = Field(default="env", description="Type: env or file")
|
|
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
|
port_override: int | None = Field(default=None, description="Port override (1-65535)")
|
|
start_command: str | None = Field(default=None, description="Override container start command")
|
|
working_directory: str | None = Field(default=None, description="Working directory inside container")
|
|
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
|
|
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
|
|
|
|
@field_validator("port_override")
|
|
@classmethod
|
|
def validate_port(cls, v: int | None) -> int | None:
|
|
if v is None:
|
|
return v
|
|
if v < 1 or v > 65535:
|
|
raise ValueError("Port must be between 1 and 65535")
|
|
return v
|
|
|
|
@field_validator("environment_variables")
|
|
@classmethod
|
|
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
|
return _validate_env_vars(v)
|
|
|
|
@field_validator("volumes")
|
|
@classmethod
|
|
def validate_volumes(cls, v: list | None) -> list | None:
|
|
return _validate_volumes(v)
|
|
|
|
|
|
class ToolConfigUpdate(BaseModel):
|
|
key: str | None = Field(default=None, description="Config key name")
|
|
value: str | None = Field(default=None, description="Config value")
|
|
config_type: str | None = Field(default=None, description="Type: env or file")
|
|
file_path: str | None = Field(default=None, description="File path for file-type configs")
|
|
port_override: int | None = Field(default=None, description="Port override (1-65535)")
|
|
start_command: str | None = Field(default=None, description="Override container start command")
|
|
working_directory: str | None = Field(default=None, description="Working directory inside container")
|
|
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
|
|
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
|
|
|
|
@field_validator("port_override")
|
|
@classmethod
|
|
def validate_port(cls, v: int | None) -> int | None:
|
|
if v is None:
|
|
return v
|
|
if v < 1 or v > 65535:
|
|
raise ValueError("Port must be between 1 and 65535")
|
|
return v
|
|
|
|
@field_validator("environment_variables")
|
|
@classmethod
|
|
def validate_env_vars(cls, v: dict | None) -> dict | None:
|
|
return _validate_env_vars(v)
|
|
|
|
@field_validator("volumes")
|
|
@classmethod
|
|
def validate_volumes(cls, v: list | None) -> list | None:
|
|
return _validate_volumes(v)
|
|
|
|
|
|
class ToolConfigResponse(BaseModel):
|
|
id: str
|
|
tool_type_id: str
|
|
project_id: str | None
|
|
key: str
|
|
value: str
|
|
config_type: str
|
|
file_path: str | None
|
|
port_override: int | None
|
|
start_command: str | None
|
|
working_directory: str | None
|
|
environment_variables: dict | None
|
|
volumes: list[dict] | None
|
|
|
|
|
|
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
|
|
async def list_configs(
|
|
tool_type_id: str | None = None,
|
|
project_id: str | None = None,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> list:
|
|
"""List tool configs for the current user."""
|
|
query = select(ToolConfig).where(ToolConfig.user_id == user_id)
|
|
|
|
if tool_type_id:
|
|
query = query.where(ToolConfig.tool_type_id == uuid.UUID(tool_type_id))
|
|
if project_id:
|
|
query = query.where(ToolConfig.project_id == uuid.UUID(project_id))
|
|
else:
|
|
# If no project specified, get only global configs (project_id is None)
|
|
query = query.where(ToolConfig.project_id.is_(None))
|
|
|
|
result = await session.execute(query)
|
|
configs = result.scalars().all()
|
|
|
|
return [
|
|
{
|
|
"id": str(c.id),
|
|
"tool_type_id": str(c.tool_type_id),
|
|
"project_id": str(c.project_id) if c.project_id else None,
|
|
"key": c.key,
|
|
"value": c.value,
|
|
"config_type": c.config_type,
|
|
"file_path": c.file_path,
|
|
"port_override": c.port_override,
|
|
"start_command": c.start_command,
|
|
"working_directory": c.working_directory,
|
|
"environment_variables": c.environment_variables,
|
|
"volumes": c.volumes,
|
|
}
|
|
for c in configs
|
|
]
|
|
|
|
|
|
@router.post("", summary="Create tool config", description="Create a new tool config.", status_code=status.HTTP_201_CREATED)
|
|
async def create_config(
|
|
data: ToolConfigCreate,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Create a tool config."""
|
|
# Verify tool type exists
|
|
tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id))
|
|
if tool_type is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
|
|
|
# Check for existing config with same key
|
|
query = select(ToolConfig).where(
|
|
ToolConfig.user_id == user_id,
|
|
ToolConfig.tool_type_id == uuid.UUID(data.tool_type_id),
|
|
ToolConfig.key == data.key,
|
|
)
|
|
if data.project_id:
|
|
query = query.where(ToolConfig.project_id == uuid.UUID(data.project_id))
|
|
else:
|
|
query = query.where(ToolConfig.project_id.is_(None))
|
|
|
|
existing = await session.scalar(query)
|
|
if existing:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"config with key '{data.key}' already exists"
|
|
)
|
|
|
|
config = ToolConfig(
|
|
user_id=user_id,
|
|
tool_type_id=uuid.UUID(data.tool_type_id),
|
|
project_id=uuid.UUID(data.project_id) if data.project_id else None,
|
|
key=data.key,
|
|
value=data.value,
|
|
config_type=data.config_type,
|
|
file_path=data.file_path,
|
|
port_override=data.port_override,
|
|
start_command=data.start_command,
|
|
working_directory=data.working_directory,
|
|
environment_variables=data.environment_variables,
|
|
volumes=data.volumes,
|
|
)
|
|
session.add(config)
|
|
await session.commit()
|
|
await session.refresh(config)
|
|
|
|
return {
|
|
"id": str(config.id),
|
|
"tool_type_id": str(config.tool_type_id),
|
|
"project_id": str(config.project_id) if config.project_id else None,
|
|
"key": config.key,
|
|
"value": config.value,
|
|
"config_type": config.config_type,
|
|
"file_path": config.file_path,
|
|
"port_override": config.port_override,
|
|
"start_command": config.start_command,
|
|
"working_directory": config.working_directory,
|
|
"environment_variables": config.environment_variables,
|
|
"volumes": config.volumes,
|
|
}
|
|
|
|
|
|
@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.")
|
|
async def update_config(
|
|
config_id: uuid.UUID,
|
|
data: ToolConfigUpdate,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Update a tool config."""
|
|
config = await session.get(ToolConfig, config_id)
|
|
if config is None or config.user_id != user_id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
|
|
|
if data.key is not None:
|
|
config.key = data.key
|
|
if data.value is not None:
|
|
config.value = data.value
|
|
if data.config_type is not None:
|
|
config.config_type = data.config_type
|
|
if data.file_path is not None:
|
|
config.file_path = data.file_path
|
|
if data.port_override is not None:
|
|
config.port_override = data.port_override
|
|
if data.start_command is not None:
|
|
config.start_command = data.start_command
|
|
if data.working_directory is not None:
|
|
config.working_directory = data.working_directory
|
|
if data.environment_variables is not None:
|
|
config.environment_variables = data.environment_variables
|
|
if data.volumes is not None:
|
|
config.volumes = data.volumes
|
|
|
|
await session.commit()
|
|
await session.refresh(config)
|
|
|
|
return {
|
|
"id": str(config.id),
|
|
"tool_type_id": str(config.tool_type_id),
|
|
"project_id": str(config.project_id) if config.project_id else None,
|
|
"key": config.key,
|
|
"value": config.value,
|
|
"config_type": config.config_type,
|
|
"file_path": config.file_path,
|
|
"port_override": config.port_override,
|
|
"start_command": config.start_command,
|
|
"working_directory": config.working_directory,
|
|
"environment_variables": config.environment_variables,
|
|
"volumes": config.volumes,
|
|
}
|
|
|
|
|
|
@router.get("/defaults/{tool_type_id}", summary="Get default configs", description="Get suggested default configs for a tool type.")
|
|
async def get_default_configs(
|
|
tool_type_id: str,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> dict:
|
|
"""Get suggested default configs for a tool type."""
|
|
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
|
if tool_type is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
|
|
|
# Return suggested defaults based on required_variables
|
|
defaults = []
|
|
for var in tool_type.required_variables:
|
|
defaults.append({
|
|
"key": var,
|
|
"value": "",
|
|
"config_type": "env",
|
|
"description": f"Required variable: {var}",
|
|
})
|
|
|
|
return {
|
|
"tool_type_id": tool_type_id,
|
|
"suggested_configs": defaults,
|
|
}
|
|
|
|
|
|
@router.delete("/{config_id}", summary="Delete tool config", description="Delete a tool config.")
|
|
async def delete_config(
|
|
config_id: uuid.UUID,
|
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
|
session: AsyncSession = Depends(get_db_session),
|
|
) -> None:
|
|
"""Delete a tool config."""
|
|
config = await session.get(ToolConfig, config_id)
|
|
if config is None or config.user_id != user_id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
|
|
|
|
await session.delete(config)
|
|
await session.commit()
|