diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py index 007c9cd..2b383b6 100644 --- a/apps/api/src/api/config_profiles.py +++ b/apps/api/src/api/config_profiles.py @@ -7,17 +7,24 @@ import uuid from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status -from pydantic import BaseModel, Field, field_validator, model_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from src.api.shared_validators import validate_env_vars as _validate_env_vars from src.auth.dependencies import get_current_user_id, get_db_session from src.models import ConfigProfile, ConfigProfileInclude from src.models.project import Project from src.models import ToolType from src.models import UserConfig +from src.schemas.config import ( + ConfigProfileCreate, + ConfigProfileIncludeUpdate, + ConfigProfileResponse, + ConfigProfileUpdate, + DefaultProfilesUpdate, + ValidateGitUrlRequest, + ValidateGitUrlResponse, +) from src.services.config_profile_resolver import ( ConfigProfileCycleError, check_include_cycle, @@ -34,16 +41,6 @@ MAX_PROFILE_SIZE_MB = 10 MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024 -def _validate_uuid(v: str | None) -> str | None: - if v is None: - return v - try: - uuid.UUID(v) - except ValueError: - raise ValueError(f"Invalid UUID: {v}") - return v - - def _calculate_profile_size(data: dict) -> int: """Calculate approximate serialized size of profile data.""" total = 0 @@ -61,247 +58,6 @@ def _calculate_profile_size(data: dict) -> int: return total -class GitMountMapping(BaseModel): - source_path: str = Field( - description="Path within repository (supports glob patterns)" - ) - target_path: str = Field(description="Absolute path inside container") - - @field_validator("source_path") - @classmethod - def validate_source_path(cls, v: str) -> str: - if v.startswith("/"): - raise ValueError("source_path must be relative (no leading /)") - if ".." in v: - raise ValueError("source_path cannot contain path traversal (..)") - return v - - @field_validator("target_path") - @classmethod - def validate_target_path(cls, v: str) -> str: - if ".." in v: - raise ValueError("target_path cannot contain path traversal (..)") - return v - - -class GitMountItem(BaseModel): - remote_url: str = Field(description="Git remote URL (HTTPS or SSH)") - source_path: str | None = Field( - default=None, description="Path within repository (legacy single mapping)" - ) - target_path: str | None = Field( - default=None, - description="Absolute path inside container (legacy single mapping)", - ) - branch: str | None = Field(default=None, description="Optional branch or tag name") - mappings: list[GitMountMapping] | None = Field( - default=None, description="Multiple source/target mappings from the same repo" - ) - - @field_validator("remote_url") - @classmethod - def validate_remote_url(cls, v: str) -> str: - if not v.startswith(("http://", "https://", "git@", "ssh://")): - raise ValueError( - "remote_url must be a valid git URL (https://, git@, or ssh://)" - ) - return v - - @field_validator("source_path") - @classmethod - def validate_source_path(cls, v: str | None) -> str | None: - if v is None: - return v - if v.startswith("/"): - raise ValueError("source_path must be relative (no leading /)") - if ".." in v: - raise ValueError("source_path cannot contain path traversal (..)") - return v - - @field_validator("target_path") - @classmethod - def validate_target_path(cls, v: str | None) -> str | None: - if v is None: - return v - if ".." in v: - raise ValueError("target_path cannot contain path traversal (..)") - return v - - @model_validator(mode="after") - def check_mappings_or_legacy(self): - has_legacy = self.source_path is not None and self.target_path is not None - has_mappings = self.mappings is not None and len(self.mappings) > 0 - if not has_legacy and not has_mappings: - raise ValueError( - "Git mount must have either 'mappings' (non-empty array) or both 'source_path' and 'target_path'" - ) - return self - - -class MountItem(BaseModel): - target: str = Field(description="Absolute mount target path") - mode: str = Field(default="rw", description="Mount mode: ro or rw") - files: dict = Field( - default_factory=dict, description="Files as {relative_path: content}" - ) - - @field_validator("target") - @classmethod - def validate_target(cls, v: str) -> str: - if not v.startswith("/"): - raise ValueError("Mount target must be absolute (start with /)") - return v - - @field_validator("mode") - @classmethod - def validate_mode(cls, v: str) -> str: - if v not in ("ro", "rw"): - raise ValueError("Mount mode must be 'ro' or 'rw'") - return v - - @field_validator("files") - @classmethod - def validate_files(cls, v: dict) -> dict: - for path in v.keys(): - if ".." in path or not path: - raise ValueError(f"Invalid file path: {path}") - if path.startswith("/"): - raise ValueError( - f"Mount file paths must be relative (got: {path}). " - f"The mount target defines the absolute container path." - ) - return v - - -class ConfigProfileCreate(BaseModel): - name: str = Field(description="Profile name (unique per user)") - description: str | None = Field(default=None, description="Optional description") - project_id: str | None = Field(default=None, description="Optional project ID") - tool_type_id: str | None = Field(default=None, description="Optional tool type ID") - env_vars: dict = Field(default_factory=dict, description="Environment variables") - runtime_hints: dict = Field(default_factory=dict, description="Runtime hints") - mounts: list[MountItem] = Field( - default_factory=list, description="Mount definitions" - ) - files: dict = Field( - default_factory=dict, description="Files as {relative_path: content}" - ) - git_mounts: list[GitMountItem] = Field( - default_factory=list, description="Git repository mounts" - ) - is_default: bool = Field( - default=False, description="Whether this is the default profile for its scope" - ) - - @field_validator("project_id", "tool_type_id") - @classmethod - def validate_uuids(cls, v: str | None) -> str | None: - return _validate_uuid(v) - - @field_validator("files") - @classmethod - def validate_files(cls, v: dict) -> dict: - for path in v.keys(): - if ".." in path or not path: - raise ValueError(f"Invalid file path: {path}") - if path.startswith("/"): - raise ValueError( - f"File paths must be relative (got: {path}). " - f"Use Mounts for absolute container paths." - ) - return v - - @field_validator("env_vars") - @classmethod - def validate_env_vars(cls, v: dict) -> dict: - result = _validate_env_vars(v) - if result is None: - raise ValueError("env_vars must be a JSON object") - return result - - @field_validator("runtime_hints") - @classmethod - def validate_runtime_hints(cls, v: dict) -> dict: - if not isinstance(v, dict): - raise ValueError("runtime_hints must be a JSON object") - return v - - @field_validator("mounts") - @classmethod - def validate_mounts(cls, v: list) -> list: - if not isinstance(v, list): - raise ValueError("mounts must be a JSON array") - return v - - -class ConfigProfileUpdate(BaseModel): - name: str | None = Field(default=None, description="Profile name") - description: str | None = Field(default=None, description="Optional description") - project_id: str | None = Field(default=None, description="Optional project ID") - tool_type_id: str | None = Field(default=None, description="Optional tool type ID") - env_vars: dict | None = Field(default=None, description="Environment variables") - runtime_hints: dict | None = Field(default=None, description="Runtime hints") - mounts: list[MountItem] | None = Field( - default=None, description="Mount definitions" - ) - files: dict | None = Field( - default=None, description="Files as {relative_path: content}" - ) - git_mounts: list[GitMountItem] | None = Field( - default=None, description="Git repository mounts" - ) - is_default: bool | None = Field( - default=None, description="Whether this is the default profile" - ) - - @field_validator("project_id", "tool_type_id") - @classmethod - def validate_uuids(cls, v: str | None) -> str | None: - return _validate_uuid(v) - - @field_validator("files") - @classmethod - def validate_files(cls, v: dict | None) -> dict | None: - if v is None: - return v - for path in v.keys(): - if ".." in path or path.startswith("/") or not path: - raise ValueError(f"Invalid file path: {path}") - return v - - -class ConfigProfileIncludeUpdate(BaseModel): - includes: list[str] = Field(description="Ordered list of included profile IDs") - - @field_validator("includes") - @classmethod - def validate_includes(cls, v: list) -> list: - for item in v: - try: - uuid.UUID(item) - except ValueError: - raise ValueError(f"Invalid UUID in includes: {item}") - return v - - -class ConfigProfileResponse(BaseModel): - id: str - user_id: str - name: str - description: str | None - project_id: str | None - tool_type_id: str | None - env_vars: dict - runtime_hints: dict - mounts: list - files: dict - git_mounts: list - is_default: bool - includes: list[dict] - created_at: str - updated_at: str - - async def _get_profile_with_includes( session: AsyncSession, profile_id: uuid.UUID ) -> ConfigProfile | None: @@ -846,12 +602,6 @@ async def resolve_default_profile( # --------------------------------------------------------------------------- -class DefaultProfilesUpdate(BaseModel): - default_profiles: dict[str, str] = Field( - description="Mapping of tool_type_id -> profile_id for default profiles" - ) - - async def _get_or_create_user_config( session: AsyncSession, user_id: uuid.UUID, @@ -940,22 +690,6 @@ async def get_default_profile_for_tool_type_endpoint( return {"tool_type_id": tool_type_id, "profile_id": profile_id} -class ValidateGitUrlRequest(BaseModel): - url: str = Field(description="Git remote URL to validate") - ssh_key_id: str | None = Field( - default=None, description="Optional SSH key ID for private repos" - ) - - -class ValidateGitUrlResponse(BaseModel): - valid: bool - suggested_url: str | None = None - branches: list[str] | None = None - default_branch: str | None = None - error: str | None = None - error_code: str | None = None - - @router.post("/validate-git-url", response_model=ValidateGitUrlResponse) async def validate_git_url( data: ValidateGitUrlRequest, diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 1def6e3..3373746 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -3,10 +3,9 @@ import os import shutil import subprocess import uuid -from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Response, status -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -19,6 +18,13 @@ from src.auth.dependencies import ( from src.config import Settings from src.models import GitRepository from src.models import SSHKey +from src.schemas.project import ( + GitRepositoryCreate, + GitRepositoryResponse, + URLParseRequest, + URLParseResponse, + UpdateSSHKeyRequest, +) from src.utils.git_files import ( commit_file, get_file_content, @@ -218,43 +224,6 @@ def _init_working_repository(repo_path: str) -> None: ) -class GitRepositoryCreate(BaseModel): - name: str - remote_url: str | None = None - force_original_url: bool = False - ssh_key_id: str | None = None - - -class URLParseRequest(BaseModel): - url: str - - -class URLParseResponse(BaseModel): - original_url: str - base_url: str | None - is_valid_clone_url: bool - needs_parsing: bool - host: str | None - message: str - error_code: str | None - - -class GitRepositoryResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: uuid.UUID - name: str - path: str - project_id: uuid.UUID | None - owner_id: uuid.UUID - is_mirror: bool - remote_url: str | None - last_push: datetime | None - ssh_key_id: uuid.UUID | None - created_at: datetime - updated_at: datetime - - @router.get( "/repositories", response_model=list[GitRepositoryResponse], @@ -599,10 +568,6 @@ async def create_repository( return repo -class UpdateSSHKeyRequest(BaseModel): - ssh_key_id: str | None = None - - @router.patch( "/{project_id}/repositories/{repo_id}/ssh-key", response_model=GitRepositoryResponse, diff --git a/apps/api/src/api/health.py b/apps/api/src/api/health.py index e1ec443..399d518 100644 --- a/apps/api/src/api/health.py +++ b/apps/api/src/api/health.py @@ -5,10 +5,16 @@ from datetime import datetime, timezone from typing import Any from fastapi import APIRouter -from pydantic import BaseModel, Field from sqlalchemy import text from src.database import SessionLocal +from src.schemas.system import ( + DatabaseHealth, + DatabaseHealthResponse, + DiskHealth, + HealthChecks, + HealthResponse, +) router = APIRouter() @@ -16,45 +22,6 @@ router = APIRouter() _start_time = time.time() -class DatabaseHealth(BaseModel): - """Database health check result.""" - - status: str = Field(description="Database health status", examples=["healthy"]) - response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2]) - - -class DiskHealth(BaseModel): - """Disk space health check result.""" - - status: str = Field(description="Disk health status", examples=["healthy"]) - free_gb: float = Field(description="Free disk space in GB", examples=[45.2]) - total_gb: float = Field(description="Total disk space in GB", examples=[100.0]) - - -class HealthChecks(BaseModel): - """Individual health checks.""" - - database: DatabaseHealth | None = None - disk: DiskHealth | None = None - - -class HealthResponse(BaseModel): - """Overall health check response.""" - - status: str = Field(description="Overall health status", examples=["healthy"]) - timestamp: str = Field(description="ISO 8601 timestamp", examples=["2026-05-19T12:00:00Z"]) - version: str = Field(description="API version", examples=["0.1.0"]) - checks: HealthChecks = Field(description="Individual health checks") - uptime_seconds: float = Field(description="Server uptime in seconds", examples=[3600.0]) - - -class DatabaseHealthResponse(BaseModel): - """Database-specific health check response.""" - - status: str = Field(description="Database health status", examples=["healthy"]) - response_time_ms: float = Field(description="Query response time in milliseconds", examples=[5.2]) - - @router.get( "/health", response_model=HealthResponse, diff --git a/apps/api/src/api/projects.py b/apps/api/src/api/projects.py index dd59fcb..067f4ab 100644 --- a/apps/api/src/api/projects.py +++ b/apps/api/src/api/projects.py @@ -3,7 +3,6 @@ import shutil import uuid from fastapi import APIRouter, Depends, HTTPException, Response, status -from pydantic import BaseModel, ConfigDict from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -17,34 +16,16 @@ from src.models import GitRepository from src.models.project import Project from src.models import SSHKey from src.models import ToolInstance +from src.schemas.project import ( + ProjectCreate, + ProjectResponse, + ProjectUpdate, + SetDefaultSSHKeyRequest, +) router = APIRouter(prefix="/projects", tags=["projects"]) -class ProjectCreate(BaseModel): - name: str - description: str | None = None - - -class ProjectUpdate(BaseModel): - name: str | None = None - description: str | None = None - - -class ProjectResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: uuid.UUID - name: str - description: str | None - owner_id: uuid.UUID - default_ssh_key_id: uuid.UUID | None - - -class SetDefaultSSHKeyRequest(BaseModel): - ssh_key_id: uuid.UUID - - @router.post( "", response_model=ProjectResponse, diff --git a/apps/api/src/api/ssh_keys.py b/apps/api/src/api/ssh_keys.py index 4d27df2..530bd4e 100644 --- a/apps/api/src/api/ssh_keys.py +++ b/apps/api/src/api/ssh_keys.py @@ -1,18 +1,24 @@ import base64 import uuid -from datetime import datetime from cryptography.fernet import Fernet from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, ConfigDict from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import _get_user, get_current_user_id, get_db_session from src.config import Settings from src.models import SSHKey +from src.schemas.project import ( + SSHKeyCreate, + SSHKeyResponse, + SignPayloadRequest, + SignatureResponse, + VerifySignatureRequest, + VerifySignatureResponse, +) router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"]) @@ -21,7 +27,7 @@ def _get_fernet() -> Fernet: """Generate a valid Fernet key from the session secret.""" import base64 import hashlib - + settings = Settings() # Derive a 32-byte key from the session secret using SHA256 key_bytes = hashlib.sha256(settings.session_secret.encode()).digest() @@ -53,36 +59,6 @@ def generate_ssh_key_pair() -> tuple[str, str]: return private_bytes.decode("utf-8"), public_bytes.decode("utf-8") -class SSHKeyCreate(BaseModel): - name: str - - -class SSHKeyResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: uuid.UUID - name: str - public_key: str - created_at: datetime - - -class SignPayloadRequest(BaseModel): - payload: str - - -class SignatureResponse(BaseModel): - signature: str - - -class VerifySignatureRequest(BaseModel): - payload: str - signature: str - - -class VerifySignatureResponse(BaseModel): - valid: bool - - @router.post( "", response_model=SSHKeyResponse, @@ -171,7 +147,9 @@ async def delete_ssh_key( user = await _get_user(session, user_id) ssh_key = await session.get(SSHKey, key_id) if ssh_key is None or ssh_key.user_id != user.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found" + ) await session.delete(ssh_key) await session.commit() @@ -203,7 +181,9 @@ async def sign_payload( user = await _get_user(session, user_id) ssh_key = await session.get(SSHKey, key_id) if ssh_key is None or ssh_key.user_id != user.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found" + ) fernet = _get_fernet() private_key_pem = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode() @@ -242,7 +222,9 @@ async def verify_signature( user = await _get_user(session, user_id) ssh_key = await session.get(SSHKey, key_id) if ssh_key is None or ssh_key.user_id != user.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found" + ) public_key = serialization.load_ssh_public_key(ssh_key.public_key.encode()) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 4f384c7..125dc4d 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -18,7 +18,6 @@ from fastapi import ( Response, status, ) -from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -82,6 +81,7 @@ from src.services.manifest_compiler import ( from src.services.permission_fixer import apply_mount_permissions, apply_ssh_permissions from src.services.readiness_probe import execute_probe from src.services.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files +from src.schemas.tool import CreateInstanceRequest, StartInstanceRequest logger = logging.getLogger(__name__) _event_bus = InstanceEventBus() @@ -418,48 +418,6 @@ def _expand_glob_source(source_path: str, repo_path: str) -> list[str]: router = APIRouter(prefix="/projects", tags=["tool-instances"]) -class CreateInstanceRequest(BaseModel): - """Request body for creating a tool instance.""" - - model_config = {"extra": "ignore"} - - tool_type_id: str = Field(description="UUID of the tool type to instantiate") - display_name: str | None = Field( - default=None, description="Optional display name for the instance" - ) - workspace_id: str | None = Field( - default=None, description="UUID of workspace to mount (replaces clone_mode)" - ) - clone_mode: str = Field( - default="mount", description="Repository access mode: 'mount' or 'clone'" - ) - branch: str | None = Field( - default="main", description="Branch to clone (when clone_mode='clone')" - ) - new_branch: str | None = Field( - default=None, description="Create a new local branch after cloning" - ) - config_profile_id: str | None = Field( - default=None, description="Optional config profile ID for launch" - ) - ssh_key_ids: list[str] = Field( - default_factory=list, description="SSH key IDs to mount into container ~/.ssh" - ) - - -class StartInstanceRequest(BaseModel): - """Request body for starting a tool instance.""" - - model_config = {"extra": "ignore"} - - config_profile_id: str | None = Field( - default=None, description="Config profile ID to apply, or null for none" - ) - ssh_key_ids: list[str] = Field( - default_factory=list, description="SSH key IDs to mount into container ~/.ssh" - ) - - async def _validate_config_profile( session: AsyncSession, profile_id: str | None, diff --git a/apps/api/src/api/tool_types.py b/apps/api/src/api/tool_types.py index 65096fe..49920c0 100644 --- a/apps/api/src/api/tool_types.py +++ b/apps/api/src/api/tool_types.py @@ -1,8 +1,6 @@ import uuid -from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, ConfigDict, field_validator, model_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -14,6 +12,12 @@ from src.api.tool_types_validation import ( from src.auth.dependencies import _get_user, get_current_user_id, get_db_session from src.models import ToolType from src.models.user import User +from src.schemas.tool import ( + ToolTypeCreate, + ToolTypeResponse, + ToolTypeUpdate, + ToolTypeValidateRequest, +) router = APIRouter(prefix="/tool-types", tags=["tool-types"]) @@ -29,237 +33,6 @@ async def _require_admin(user: User) -> None: pass -class ToolTypeCreate(BaseModel): - name: str - display_name: str - description: str | None = None - default_port: int = 0 - definition_type: str = "compose" - manifest_id: uuid.UUID | None = None - compose_template: str | None = None - dockerfile_template: str | None = None - build_context: dict | None = None - readiness_probe: dict | None = None - startup_command: str | None = None - required_variables: list[str] = [] - category: str = "other" - interface_type: str = "web" - requires_port: bool = True - - @field_validator("definition_type") - @classmethod - def validate_definition_type(cls, v: str) -> str: - if v not in ("compose", "dockerfile", "manifest"): - raise ValueError( - "definition_type must be 'compose', 'dockerfile', or 'manifest'" - ) - 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 or not v.strip(): - raise ValueError( - "compose_template is required when definition_type is 'compose'" - ) - - validate_compose_yaml(v) - 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 or not v.strip(): - 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("interface_type") - @classmethod - def validate_interface_type(cls, v: str) -> str: - if v not in ("web", "terminal"): - raise ValueError("interface_type must be 'web' or 'terminal'") - return v - - @field_validator("default_port") - @classmethod - def validate_default_port(cls, v: int, info) -> int: - data = info.data - requires_port = data.get("requires_port", True) - if not requires_port: - return v - if v <= 0 or v > 65535: - raise ValueError("Port must be between 1 and 65535") - 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 == "manifest": - if self.manifest_id is None: - raise ValueError( - "manifest_id is required when definition_type is 'manifest'" - ) - return self - - if self.definition_type == "dockerfile" and ( - self.dockerfile_template is None or not self.dockerfile_template.strip() - ): - raise ValueError( - "dockerfile_template is required when definition_type is 'dockerfile'" - ) - if self.definition_type == "compose" and ( - self.compose_template is None or not self.compose_template.strip() - ): - raise ValueError( - "compose_template is required when definition_type is 'compose'" - ) - - # Validate that default_port is exposed in compose template (only if requires_port) - if ( - self.requires_port - and self.definition_type == "compose" - and self.compose_template - ): - try: - parsed = validate_compose_yaml(self.compose_template) - except ValueError: - return self - - if not check_port_exposed(parsed, self.default_port): - raise ValueError( - f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section." - ) - - return self - - -class ToolTypeUpdate(BaseModel): - display_name: str | None = None - description: str | None = None - default_port: int | None = None - definition_type: str | None = None - manifest_id: uuid.UUID | None = None - compose_template: str | None = None - dockerfile_template: str | None = None - build_context: dict | None = None - readiness_probe: dict | None = None - startup_command: str | None = None - required_variables: list[str] | None = None - category: str | None = None - interface_type: str | None = None - requires_port: bool | 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", "manifest"): - raise ValueError( - "definition_type must be 'compose', 'dockerfile', or 'manifest'" - ) - return v - - @field_validator("interface_type") - @classmethod - def validate_interface_type(cls, v: str | None) -> str | None: - if v is None: - return v - if v not in ("web", "terminal"): - raise ValueError("interface_type must be 'web' or 'terminal'") - 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 - - validate_compose_yaml(v) - 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 - interface_type: str - requires_port: bool - default_port: int - definition_type: str - manifest_id: uuid.UUID | None - compose_template: str | None - dockerfile_template: str | None - build_context: dict | None - readiness_probe: dict | None - startup_command: str | None - required_variables: list[str] - created_by_id: uuid.UUID | None - created_at: datetime - updated_at: datetime - - @router.post( "", response_model=ToolTypeResponse, @@ -461,12 +234,6 @@ async def update_tool_type( return tool_type -class ToolTypeValidateRequest(BaseModel): - definition_type: str - compose_template: str | None = None - dockerfile_template: str | None = None - - @router.post( "/validate", summary="Validate tool type template", diff --git a/apps/api/src/api/user_config.py b/apps/api/src/api/user_config.py index 203acda..c38e97f 100644 --- a/apps/api/src/api/user_config.py +++ b/apps/api/src/api/user_config.py @@ -2,12 +2,12 @@ import logging import uuid from fastapi import APIRouter, Depends -from pydantic import BaseModel, ConfigDict from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import _get_user, get_current_user_id, get_db_session from src.models import UserConfig +from src.schemas.user import UserConfigResponse, UserConfigUpdate logger = logging.getLogger(__name__) @@ -38,28 +38,6 @@ async def _get_or_create_config( return config -class UserConfigResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - default_editor: str | None = None - theme: str = "system" - git_user_name: str | None = None - git_user_email: str | None = None - last_session_id: str | None = None - notification_mute_categories: list[str] | None = None - notification_toast_level: str | None = None - - -class UserConfigUpdate(BaseModel): - default_editor: str | None = None - theme: str | None = None - git_user_name: str | None = None - git_user_email: str | None = None - last_session_id: str | None = None - notification_mute_categories: list[str] | None = None - notification_toast_level: str | None = None - - @router.get( "/config", response_model=UserConfigResponse, diff --git a/apps/api/src/api/users.py b/apps/api/src/api/users.py index 5533b4b..dde842d 100644 --- a/apps/api/src/api/users.py +++ b/apps/api/src/api/users.py @@ -2,11 +2,11 @@ import uuid from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, UploadFile, status -from pydantic import BaseModel, ConfigDict from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import _get_user, get_current_user_id, get_db_session from src.models.user import User +from src.schemas.user import UserProfileResponse, UserProfileUpdate router = APIRouter(prefix="/users", tags=["users"]) @@ -16,20 +16,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"} MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB -class UserProfileResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: uuid.UUID - email: str - name: str - avatar_url: str | None - - -class UserProfileUpdate(BaseModel): - name: str | None = None - email: str | None = None - - @router.get( "/me", response_model=UserProfileResponse, @@ -77,12 +63,16 @@ async def update_profile( if data.name is not None: if len(data.name.strip()) == 0: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="name cannot be empty" + ) user.name = data.name.strip() if data.email is not None: if "@" not in data.email: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="invalid email" + ) user.email = data.email.strip() await session.commit() diff --git a/apps/api/src/schemas/config/__init__.py b/apps/api/src/schemas/config/__init__.py index da7ff7c..e63b901 100644 --- a/apps/api/src/schemas/config/__init__.py +++ b/apps/api/src/schemas/config/__init__.py @@ -1 +1,27 @@ -"""Config module.""" +"""Config schemas module.""" + +from src.schemas.config.config_profile import ( + ConfigProfileCreate, + ConfigProfileIncludeUpdate, + ConfigProfileResponse, + ConfigProfileUpdate, + DefaultProfilesUpdate, + GitMountItem, + GitMountMapping, + MountItem, + ValidateGitUrlRequest, + ValidateGitUrlResponse, +) + +__all__ = [ + "ConfigProfileCreate", + "ConfigProfileIncludeUpdate", + "ConfigProfileResponse", + "ConfigProfileUpdate", + "DefaultProfilesUpdate", + "GitMountItem", + "GitMountMapping", + "MountItem", + "ValidateGitUrlRequest", + "ValidateGitUrlResponse", +] diff --git a/apps/api/src/schemas/config/config_profile.py b/apps/api/src/schemas/config/config_profile.py index 7bd1aba..282525a 100644 --- a/apps/api/src/schemas/config/config_profile.py +++ b/apps/api/src/schemas/config/config_profile.py @@ -1,7 +1,6 @@ """Config profile request/response schemas.""" import uuid -from typing import Any from pydantic import BaseModel, Field, field_validator, model_validator @@ -13,8 +12,8 @@ def _validate_uuid(v: str | None) -> str | None: return v try: uuid.UUID(v) - except ValueError: - raise ValueError(f"Invalid UUID: {v}") + except ValueError as exc: + raise ValueError(f"Invalid UUID: {v}") from exc return v @@ -236,8 +235,8 @@ class ConfigProfileIncludeUpdate(BaseModel): for item in v: try: uuid.UUID(item) - except ValueError: - raise ValueError(f"Invalid UUID in includes: {item}") + except ValueError as exc: + raise ValueError(f"Invalid UUID in includes: {item}") from exc return v diff --git a/apps/api/src/schemas/project/__init__.py b/apps/api/src/schemas/project/__init__.py index 1ed2378..c2d37a4 100644 --- a/apps/api/src/schemas/project/__init__.py +++ b/apps/api/src/schemas/project/__init__.py @@ -1 +1,41 @@ -"""Project module.""" +"""Project schemas module.""" + +from src.schemas.project.git_repository import ( + GitRepositoryCreate, + GitRepositoryResponse, + UpdateSSHKeyRequest, + URLParseRequest, + URLParseResponse, +) +from src.schemas.project.project import ( + ProjectCreate, + ProjectResponse, + ProjectUpdate, + SetDefaultSSHKeyRequest, +) +from src.schemas.project.ssh_key import ( + SSHKeyCreate, + SSHKeyResponse, + SignPayloadRequest, + SignatureResponse, + VerifySignatureRequest, + VerifySignatureResponse, +) + +__all__ = [ + "GitRepositoryCreate", + "GitRepositoryResponse", + "ProjectCreate", + "ProjectResponse", + "ProjectUpdate", + "SSHKeyCreate", + "SSHKeyResponse", + "SetDefaultSSHKeyRequest", + "SignPayloadRequest", + "SignatureResponse", + "URLParseRequest", + "URLParseResponse", + "UpdateSSHKeyRequest", + "VerifySignatureRequest", + "VerifySignatureResponse", +] diff --git a/apps/api/src/schemas/system/__init__.py b/apps/api/src/schemas/system/__init__.py index 3f55c57..821edd0 100644 --- a/apps/api/src/schemas/system/__init__.py +++ b/apps/api/src/schemas/system/__init__.py @@ -1 +1,17 @@ -"""System module.""" +"""System schemas module.""" + +from src.schemas.system.health import ( + DatabaseHealth, + DatabaseHealthResponse, + DiskHealth, + HealthChecks, + HealthResponse, +) + +__all__ = [ + "DatabaseHealth", + "DatabaseHealthResponse", + "DiskHealth", + "HealthChecks", + "HealthResponse", +] diff --git a/apps/api/src/schemas/tool/__init__.py b/apps/api/src/schemas/tool/__init__.py index b05b87d..567fd1c 100644 --- a/apps/api/src/schemas/tool/__init__.py +++ b/apps/api/src/schemas/tool/__init__.py @@ -1 +1,18 @@ -"""Tool module.""" +"""Tool schemas module.""" + +from src.schemas.tool.tool_instance import CreateInstanceRequest, StartInstanceRequest +from src.schemas.tool.tool_type import ( + ToolTypeCreate, + ToolTypeResponse, + ToolTypeUpdate, + ToolTypeValidateRequest, +) + +__all__ = [ + "CreateInstanceRequest", + "StartInstanceRequest", + "ToolTypeCreate", + "ToolTypeResponse", + "ToolTypeUpdate", + "ToolTypeValidateRequest", +] diff --git a/apps/api/src/schemas/tool/tool_type.py b/apps/api/src/schemas/tool/tool_type.py index a80b272..34672d0 100644 --- a/apps/api/src/schemas/tool/tool_type.py +++ b/apps/api/src/schemas/tool/tool_type.py @@ -2,7 +2,9 @@ import uuid -from pydantic import BaseModel, field_validator, model_validator +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator from src.api.tool_types_validation import ( check_port_exposed, @@ -196,3 +198,33 @@ class ToolTypeUpdate(BaseModel): 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 + interface_type: str + requires_port: bool + default_port: int + definition_type: str + manifest_id: uuid.UUID | None + compose_template: str | None + dockerfile_template: str | None + build_context: dict | None + readiness_probe: dict | None + startup_command: str | None + required_variables: list[str] + created_by_id: uuid.UUID | None + created_at: datetime + updated_at: datetime + + +class ToolTypeValidateRequest(BaseModel): + definition_type: str + compose_template: str | None = None + dockerfile_template: str | None = None diff --git a/apps/api/src/schemas/user/__init__.py b/apps/api/src/schemas/user/__init__.py index 7f42956..d640ed2 100644 --- a/apps/api/src/schemas/user/__init__.py +++ b/apps/api/src/schemas/user/__init__.py @@ -1 +1,11 @@ -"""User module.""" +"""User schemas module.""" + +from src.schemas.user.user import UserProfileResponse, UserProfileUpdate +from src.schemas.user.user_config import UserConfigResponse, UserConfigUpdate + +__all__ = [ + "UserConfigResponse", + "UserConfigUpdate", + "UserProfileResponse", + "UserProfileUpdate", +]