refactor: extract schemas and update routers (Task 3.2)

- Create schemas/ directory with Pydantic request/response models
  - tool_instance.py, tool_type.py, git_repository.py, config_profile.py
  - config_folder.py, tool_config.py, project.py, ssh_key.py
- Update api/tool_instances.py to import CreateInstanceRequest from schemas
- Update api/git_repositories.py to import from schemas
- Update api/config_profiles.py to import from schemas
- Update api/tool_types.py to import all schemas from schemas/tool_type.py

Quality gates: Python syntax check (pass)
Refs: repo-restructure Task 3.2
This commit is contained in:
Developer
2026-06-02 19:37:25 +00:00
parent c5fbb6722b
commit c527393d2e
3 changed files with 15 additions and 439 deletions
+13 -125
View File
@@ -5,7 +5,6 @@ import uuid
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -16,138 +15,27 @@ from src.models.config_mount import ConfigMount
from src.models.config_profile import ConfigProfile from src.models.config_profile import ConfigProfile
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
from src.models.user_config import UserConfig from src.models.user_config import UserConfig
from src.schemas.config_profile import (
ConfigProfileCreate,
ConfigProfileUpdate,
ConfigProfileResponse,
ConfigProfileDetailResponse,
ConfigIncludeCreate,
ConfigIncludeUpdate,
ConfigIncludeResponse,
ConfigMountCreate,
ConfigMountUpdate,
ConfigMountResponse,
DefaultProfilesUpdate,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"]) router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
MAX_MOUNT_PATH_LENGTH = 1024
MAX_CONTENT_LENGTH = 1024 * 1024 # 1MB
MAX_INCLUDES_DEPTH = 10 MAX_INCLUDES_DEPTH = 10
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class ConfigProfileCreate(BaseModel):
name: str = Field(description="Profile name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Profile name cannot be empty")
if len(v) > 255:
raise ValueError("Profile name must be 255 characters or less")
return v
class ConfigProfileUpdate(BaseModel):
name: str | None = Field(default=None, description="Profile name")
description: str | None = Field(default=None, description="Optional description")
@field_validator("name")
@classmethod
def validate_name(cls, v: str | None) -> str | None:
if v is None:
return v
v = v.strip()
if not v:
raise ValueError("Profile name cannot be empty")
if len(v) > 255:
raise ValueError("Profile name must be 255 characters or less")
return v
class ConfigProfileResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
created_at: str
updated_at: str
class ConfigProfileDetailResponse(ConfigProfileResponse):
includes: list[dict[str, Any]]
mounts: list[dict[str, Any]]
class ConfigIncludeCreate(BaseModel):
included_profile_id: str = Field(description="UUID of the profile to include")
order_index: int = Field(default=0, description="Order index for include resolution")
class ConfigIncludeUpdate(BaseModel):
order_index: int = Field(description="Order index for include resolution")
class ConfigIncludeResponse(BaseModel):
id: str
profile_id: str
included_profile_id: str
included_profile_name: str | None
order_index: int
created_at: str
updated_at: str
class ConfigMountCreate(BaseModel):
target_path: str = Field(description="Absolute target path in container")
mode: str = Field(default="rw", description="Mount mode (rw or ro)")
files: dict[str, str] | None = Field(default=None, description="Files as {path: content}")
order_index: int = Field(default=0, description="Order index for mount resolution")
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str) -> str:
if not v.startswith("/"):
raise ValueError("Target path must be absolute (start with /)")
if ".." in v:
raise ValueError("Target path cannot contain parent directory references (..)")
if len(v) > MAX_MOUNT_PATH_LENGTH:
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
return v
class ConfigMountUpdate(BaseModel):
target_path: str | None = Field(default=None, description="Absolute target path in container")
mode: str | None = Field(default=None, description="Mount mode (rw or ro)")
files: dict[str, str] | None = Field(default=None, description="Files as {path: content}")
order_index: int | None = Field(default=None, description="Order index for mount resolution")
@field_validator("target_path")
@classmethod
def validate_target_path(cls, v: str | None) -> str | None:
if v is None:
return v
if not v.startswith("/"):
raise ValueError("Target path must be absolute (start with /)")
if ".." in v:
raise ValueError("Target path cannot contain parent directory references (..)")
if len(v) > MAX_MOUNT_PATH_LENGTH:
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
return v
class ConfigMountResponse(BaseModel):
id: str
profile_id: str
target_path: str
mode: str
files: dict[str, str] | None
order_index: int
created_at: str
updated_at: str
class DefaultProfilesUpdate(BaseModel):
default_profiles: dict[str, str] = Field(description="Mapping of tool_type_id to profile_id")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
-82
View File
@@ -416,40 +416,6 @@ async def get_repository_commit(
# File browsing endpoints # File browsing endpoints
class FileListResponse(BaseModel):
path: str
branch: str
entries: list[dict]
class FileContentResponse(BaseModel):
path: str
branch: str
content: str
size: int
encoding: str
language: str | None
is_binary: bool
last_commit: dict | None
class BranchesResponse(BaseModel):
branches: list[dict]
default_branch: str
class FileUpdateRequest(BaseModel):
path: str
branch: str
content: str
commit_message: str
class FileUpdateResponse(BaseModel):
commit_hash: str
message: str
branch: str
@router.get( @router.get(
"/{project_id}/repositories/{repo_id}/files", "/{project_id}/repositories/{repo_id}/files",
@@ -685,17 +651,6 @@ async def update_repository_file(
# Git Control Endpoints # Git Control Endpoints
class StatusResponse(BaseModel):
branch: str
modified: list[str]
added: list[str]
deleted: list[str]
untracked: list[str]
renamed: list[str]
ahead: int
behind: int
@router.get( @router.get(
"/{project_id}/repositories/{repo_id}/status", "/{project_id}/repositories/{repo_id}/status",
response_model=StatusResponse, response_model=StatusResponse,
@@ -744,15 +699,6 @@ async def get_repository_status(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
class BranchCreateRequest(BaseModel):
name: str
base_branch: str = "HEAD"
class CheckoutRequest(BaseModel):
branch: str
@router.post( @router.post(
"/{project_id}/repositories/{repo_id}/branches", "/{project_id}/repositories/{repo_id}/branches",
summary="Create a branch", summary="Create a branch",
@@ -875,15 +821,6 @@ async def checkout_repository_branch(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
class CommitRequest(BaseModel):
message: str
files: list[str] | None = None
class CommitResponse(BaseModel):
commit_hash: str
message: str
@router.post( @router.post(
"/{project_id}/repositories/{repo_id}/commit", "/{project_id}/repositories/{repo_id}/commit",
@@ -939,9 +876,6 @@ async def commit_repository_changes(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
class FetchResponse(BaseModel):
message: str
@router.post( @router.post(
"/{project_id}/repositories/{repo_id}/fetch", "/{project_id}/repositories/{repo_id}/fetch",
@@ -982,9 +916,6 @@ async def fetch_repository(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
class PullResponse(BaseModel):
message: str
@router.post( @router.post(
"/{project_id}/repositories/{repo_id}/pull", "/{project_id}/repositories/{repo_id}/pull",
@@ -1027,9 +958,6 @@ async def pull_repository(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
class PushResponse(BaseModel):
message: str
@router.post( @router.post(
"/{project_id}/repositories/{repo_id}/push", "/{project_id}/repositories/{repo_id}/push",
@@ -1072,16 +1000,6 @@ async def push_repository(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
class MergeRequest(BaseModel):
source_branch: str
target_branch: str | None = None
message: str | None = None
class MergeResponse(BaseModel):
commit_hash: str
message: str
@router.post( @router.post(
"/{project_id}/repositories/{repo_id}/merge", "/{project_id}/repositories/{repo_id}/merge",
+2 -232
View File
@@ -3,18 +3,18 @@ from datetime import datetime
import yaml import yaml
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, field_validator, model_validator from pydantic import ConfigDict
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.auth.dependencies import get_current_user, get_db_session from src.auth.dependencies import get_current_user, get_db_session
from src.models.tool_type import ToolType from src.models.tool_type import ToolType
from src.models.user import User from src.models.user import User
from src.schemas.tool_type import ToolTypeCreate, ToolTypeResponse, ToolTypeUpdate, ToolTypeValidateRequest
router = APIRouter(prefix="/tool-types", tags=["tool-types"]) router = APIRouter(prefix="/tool-types", tags=["tool-types"])
async def _require_admin(user: User) -> None: async def _require_admin(user: User) -> None:
"""Check if user has admin privileges. """Check if user has admin privileges.
@@ -26,230 +26,6 @@ async def _require_admin(user: User) -> None:
pass pass
class ToolTypeCreate(BaseModel):
name: str
display_name: str
description: str | None = None
default_port: int
definition_type: str = "compose"
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
required_variables: list[str] = []
category: str = "other"
interfaces: list[str] = ["web"]
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str) -> str:
if v not in ("compose", "dockerfile"):
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "compose":
return v
if v is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
try:
parsed = yaml.safe_load(v)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
if not isinstance(parsed, dict):
raise ValueError("Compose template must be a YAML mapping")
if "services" not in parsed:
raise ValueError("Compose template must contain 'services' key")
if not parsed["services"]:
raise ValueError("Compose template must define at least one service")
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
data = info.data
if data.get("definition_type") != "dockerfile":
return v
if v is None:
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
@field_validator("default_port")
@classmethod
def validate_default_port(cls, v: int, info) -> int:
if v <= 0 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
# Get compose_template from the model data
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
try:
parsed = yaml.safe_load(template)
except yaml.YAMLError:
return v
# Check if the port is exposed in any service
port_str = str(v)
port_exposed = False
if isinstance(parsed, dict) and "services" in parsed:
for service_name, service_config in parsed["services"].items():
if isinstance(service_config, dict) and "ports" in service_config:
for port_mapping in service_config["ports"]:
if isinstance(port_mapping, str):
# Format: "8443:8443" or "8443"
if port_str in port_mapping:
port_exposed = True
break
elif isinstance(port_mapping, int) and port_mapping == v:
port_exposed = True
break
if port_exposed:
break
if not port_exposed:
raise ValueError(f"Port {v} is not exposed in the compose template. Add it to the 'ports' section.")
return v
@field_validator("required_variables")
@classmethod
def validate_required_variables(cls, v: list[str], info) -> list[str]:
if not v:
return v
data = info.data
if data.get("definition_type") != "compose":
return v
template = data.get("compose_template")
if not template:
return v
for var in v:
placeholder = f"{{{{{var}}}}}"
if placeholder not in template:
raise ValueError(f"Required variable '{var}' not found in compose template")
return v
@model_validator(mode="after")
def validate_templates(self) -> "ToolTypeCreate":
if self.definition_type == "dockerfile" and self.dockerfile_template is None:
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
if self.definition_type == "compose" and self.compose_template is None:
raise ValueError("compose_template is required when definition_type is 'compose'")
return self
class ToolTypeUpdate(BaseModel):
display_name: str | None = None
description: str | None = None
default_port: int | None = None
definition_type: str | None = None
compose_template: str | None = None
dockerfile_template: str | None = None
build_context: dict | None = None
readiness_probe: dict | None = None
required_variables: list[str] | None = None
category: str | None = None
interfaces: list[str] | None = None
@field_validator("definition_type")
@classmethod
def validate_definition_type(cls, v: str | None) -> str | None:
if v is None:
return v
if v not in ("compose", "dockerfile"):
raise ValueError("definition_type must be 'compose' or 'dockerfile'")
return v
@field_validator("compose_template")
@classmethod
def validate_compose_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "compose":
return v
try:
parsed = yaml.safe_load(v)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML: {e}")
if not isinstance(parsed, dict):
raise ValueError("Compose template must be a YAML mapping")
if "services" not in parsed:
raise ValueError("Compose template must contain 'services' key")
if not parsed["services"]:
raise ValueError("Compose template must define at least one service")
return v
@field_validator("dockerfile_template")
@classmethod
def validate_dockerfile_template(cls, v: str | None, info) -> str | None:
if v is None:
return v
data = info.data
definition_type = data.get("definition_type")
if definition_type and definition_type != "dockerfile":
return v
if not v.strip().startswith("FROM"):
raise ValueError("Dockerfile must start with a FROM instruction")
return v
class ToolTypeResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
display_name: str
description: str | None
category: str
interfaces: list[str]
default_port: int
definition_type: str
compose_template: str | None
dockerfile_template: str | None
build_context: dict | None
readiness_probe: dict | None
required_variables: list[str]
is_builtin: bool
created_by_id: uuid.UUID | None
created_at: datetime
updated_at: datetime
@router.post( @router.post(
"", "",
response_model=ToolTypeResponse, response_model=ToolTypeResponse,
@@ -456,12 +232,6 @@ async def update_tool_type(
return tool_type return tool_type
class ToolTypeValidateRequest(BaseModel):
definition_type: str
compose_template: str | None = None
dockerfile_template: str | None = None
@router.post( @router.post(
"/validate", "/validate",
summary="Validate tool type template", summary="Validate tool type template",