diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..270ba96 --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,252 @@ +# Headquarter API + +The backend API for Headquarter - a self-hosted platform for managing projects, git repositories, and development tools. + +## Overview + +Built with **FastAPI** and **SQLAlchemy** (async), using **PostgreSQL** for data storage and **Docker** for tool instance management. + +### Tech Stack + +- **Framework**: FastAPI (Python 3.12+) +- **Database**: PostgreSQL 15+ with asyncpg +- **ORM**: SQLAlchemy 2.0 (async) +- **Auth**: OAuth2 via Authentik with session cookies +- **Migrations**: Alembic +- **Tools**: Docker Compose for instance management + +## Quick Start + +### Prerequisites + +- Python 3.12+ +- PostgreSQL 15+ running locally +- Docker (for tool instances) + +### Setup + +```bash +cd apps/api + +# Create virtual environment +python -m venv .venv +source .venv/bin/activate + +# Install dependencies +pip install -e ".[dev]" + +# Set up database +# Ensure PostgreSQL is running with a 'headquarter' database + +# Run migrations +alembic upgrade head + +# Start development server +uvicorn src.main:app --reload --port 8000 +``` + +The API will be available at `http://localhost:8000`. + +### Interactive Documentation + +Once running, visit: +- **Swagger UI**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc +- **OpenAPI JSON**: http://localhost:8000/openapi.json + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `DATABASE_URL` | Yes | - | PostgreSQL connection string | +| `API_BASE_URL` | Yes | - | Public API URL (e.g., `https://api.example.com`) | +| `AUTHENTIK_DOMAIN` | Yes | - | Authentik server domain | +| `AUTHENTIK_CLIENT_ID` | Yes | - | OAuth2 client ID | +| `AUTHENTIK_CLIENT_SECRET` | Yes | - | OAuth2 client secret | +| `AUTHENTIK_APPLICATION_SLUG` | Yes | - | Authentik application slug | +| `WEB_BASE_URL` | Yes | - | Public frontend URL | +| `SESSION_SECRET` | Yes | - | Secret for session cookie signing | +| `COOKIE_DOMAIN` | No | - | Cookie domain (e.g., `.example.com`) | +| `UPLOAD_DIR` | No | `./uploads` | Directory for file uploads | +| `REPO_BASE_PATH` | No | `./repositories` | Base path for git repositories | +| `INSTANCES_BASE_PATH` | No | `./instances` | Base path for tool instances | +| `LOG_LEVEL` | No | `INFO` | Logging level | + +## Development + +### Running Tests + +```bash +# Run all tests +pytest + +# Run specific test category +pytest -m unit # Unit tests (no DB) +pytest -m integration # Integration tests (requires DB) + +# Run with coverage +pytest --cov=src --cov-report=html +``` + +### Code Quality + +```bash +# Format code +ruff format src tests + +# Lint +ruff check src tests + +# Type check +mypy src +``` + +### Database Migrations + +```bash +# Create new migration +alembic revision --autogenerate -m "description" + +# Apply migrations +alembic upgrade head + +# Rollback one migration +alembic downgrade -1 + +# Show current revision +alembic current +``` + +## Architecture + +### Directory Structure + +``` +src/ +├── api/ # API endpoint routers +│ ├── auth.py # OAuth2 authentication +│ ├── dashboard.py # Dashboard summary +│ ├── git_repositories.py # Git repo management +│ ├── health.py # Health checks +│ ├── projects.py # Project CRUD +│ ├── ssh_keys.py # SSH key management +│ ├── terminal.py # WebSocket terminal +│ ├── tool_instances.py # Tool instance management +│ ├── tool_types.py # Tool type definitions +│ ├── user_config.py # User preferences +│ └── users.py # User profile +├── auth/ # Authentication logic +│ ├── cookies.py # Cookie utilities +│ ├── dependencies.py # Auth dependencies +│ ├── oidc.py # OpenID Connect +│ └── session.py # Session management +├── config.py # Application settings +├── database.py # Database setup +├── main.py # FastAPI application +├── models/ # SQLAlchemy models +├── schemas/ # Pydantic schemas +├── services/ # Business logic +│ ├── docker.py # Docker Compose management +│ ├── terminal_manager.py # Terminal sessions +│ └── terminal_session.py # Terminal I/O +└── utils/ # Utilities + ├── git_control.py # Git operations + ├── git_files.py # File operations + ├── git_history.py # History extraction + └── git_url_parser.py # URL parsing +``` + +### Authentication Flow + +1. User clicks "Login" → redirects to Authentik OAuth +2. Authentik redirects back with authorization code +3. API exchanges code for tokens and fetches user info +4. API creates session cookie (HMAC-signed, httpOnly) +5. Frontend stores nothing - cookie sent automatically +6. Subsequent requests include cookie for authentication + +### Data Flow + +``` +Client → FastAPI Router → Auth Dependency → Service Layer → Database + ↓ + Pydantic Models (validation) + ↓ + SQLAlchemy Models (ORM) + ↓ + PostgreSQL (storage) +``` + +## API Endpoints + +### Authentication +- `GET /auth/login` - Initiate OAuth login +- `GET /auth/callback` - OAuth callback +- `GET /auth/me` - Get current user +- `POST /auth/logout` - Logout + +### Projects +- `GET /projects` - List projects +- `POST /projects` - Create project +- `GET /projects/{id}` - Get project +- `PUT /projects/{id}` - Update project +- `DELETE /projects/{id}` - Delete project + +### Git Repositories +- `GET /projects/{id}/repositories` - List repositories +- `POST /projects/{id}/repositories` - Create repository +- `GET /projects/{id}/repositories/{id}` - Get repository +- `DELETE /projects/{id}/repositories/{id}` - Delete repository +- `GET /projects/{id}/repositories/{id}/files` - List files +- `GET /projects/{id}/repositories/{id}/files/content` - Get file content +- `POST /projects/{id}/repositories/{id}/files/content` - Update file +- `GET /projects/{id}/repositories/{id}/branches` - List branches +- `GET /projects/{id}/repositories/{id}/history` - Commit history +- `GET /projects/{id}/repositories/{id}/commits/{hash}` - Commit detail + +### Tool Types +- `GET /tool-types` - List tool types +- `POST /tool-types` - Create tool type +- `GET /tool-types/{id}` - Get tool type +- `PUT /tool-types/{id}` - Update tool type +- `DELETE /tool-types/{id}` - Delete tool type + +### Tool Instances +- `GET /tool-instances` - List instances +- `POST /tool-instances` - Create instance +- `GET /tool-instances/{id}` - Get instance +- `POST /tool-instances/{id}/start` - Start instance +- `POST /tool-instances/{id}/stop` - Stop instance +- `POST /tool-instances/{id}/restart` - Restart instance +- `DELETE /tool-instances/{id}` - Delete instance +- `GET /tool-instances/{id}/logs` - Get logs + +### Terminal +- `WS /ws/tool-instances/{id}/terminal` - WebSocket terminal + +### Users +- `GET /users/me` - Get profile +- `PUT /users/me` - Update profile +- `POST /users/me/avatar` - Upload avatar +- `GET /users/me/config` - Get config +- `PATCH /users/me/config` - Update config + +### SSH Keys +- `GET /ssh-keys` - List keys +- `POST /ssh-keys` - Create key +- `DELETE /ssh-keys/{id}` - Delete key + +### Health +- `GET /health` - System health +- `GET /health/db` - Database health + +## Deployment + +See the [deployment documentation](../../docs/deployment/) for Docker and Traefik setup. + +## Contributing + +1. Follow PEP 8 style guide +2. Add tests for new endpoints +3. Update documentation +4. Run quality gates before committing diff --git a/apps/api/src/api/auth.py b/apps/api/src/api/auth.py index a6e157e..85ab1f1 100644 --- a/apps/api/src/api/auth.py +++ b/apps/api/src/api/auth.py @@ -25,8 +25,21 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]: yield session -@router.get("/login") +@router.get( + "/login", + summary="Initiate OAuth login", + description="Redirects to the configured OAuth provider (Authentik) to start the authentication flow.", + response_class=RedirectResponse, +) async def login(next: str = "/") -> RedirectResponse: + """Initiate OAuth2 login flow. + + Args: + next: URL to redirect to after successful authentication. + + Returns: + RedirectResponse to the OAuth provider's authorization endpoint. + """ settings = Settings() redirect_uri = f"{settings.api_base_url}/auth/callback" state = token_urlsafe(24) diff --git a/apps/api/src/api/dashboard.py b/apps/api/src/api/dashboard.py index 10d89f4..b9059f3 100644 --- a/apps/api/src/api/dashboard.py +++ b/apps/api/src/api/dashboard.py @@ -12,11 +12,24 @@ from src.models.ssh_key import SSHKey router = APIRouter(prefix="/dashboard", tags=["dashboard"]) -@router.get("/summary") +@router.get( + "/summary", + summary="Get dashboard summary", + description="Get a summary of the user's projects, repositories, SSH keys, and recent activity.", +) async def get_dashboard_summary( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: + """Get a summary of the user's dashboard data. + + Args: + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary with counts of projects, repositories, SSH keys, and recent activity. + """ # Count user's projects projects_result = await session.execute( select(func.count()).select_from(Project).where(Project.owner_id == user_id) diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 9b19a8f..184ce34 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -38,6 +38,7 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"]) async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: + """Fetch a user by ID or raise 401 if not found.""" user = await session.get(User, user_id) if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") @@ -49,6 +50,19 @@ async def _get_owned_project( user_id: uuid.UUID, session: AsyncSession, ) -> Project: + """Fetch a project and verify ownership. + + Args: + project_id: UUID of the project. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The project if found and owned by the user. + + Raises: + HTTPException: If project not found or user is not the owner. + """ project = await session.get(Project, project_id) if project is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found") @@ -58,6 +72,16 @@ async def _get_owned_project( def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str: + """Generate the filesystem path for a repository. + + Args: + user_id: UUID of the repository owner. + project_id: UUID of the project. + name: Repository name. + + Returns: + Absolute path to the repository directory. + """ base = Settings().repo_base_path or "/data/repos" return os.path.join(base, str(user_id), str(project_id), f"{name}.git") @@ -97,12 +121,27 @@ class GitRepositoryResponse(BaseModel): updated_at: datetime -@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse]) +@router.get( + "/{project_id}/repositories", + response_model=list[GitRepositoryResponse], + summary="List repositories", + description="List all git repositories in a project.", +) async def list_repositories( project_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> list[GitRepository]: + """List all repositories in a project. + + Args: + project_id: UUID of the project. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + List of repositories in the project. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -112,13 +151,29 @@ async def list_repositories( return list(result.scalars().all()) -@router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete( + "/{project_id}/repositories/{repo_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a repository", + description="Delete a git repository from the project and remove it from disk.", +) async def delete_repository( project_id: uuid.UUID, repo_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> Response: + """Delete a repository. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository to delete. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Empty response with 204 status code. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -135,20 +190,49 @@ async def delete_repository( return Response(status_code=status.HTTP_204_NO_CONTENT) -@router.post("/repositories/parse-url", response_model=URLParseResponse) +@router.post( + "/repositories/parse-url", + response_model=URLParseResponse, + summary="Parse a git URL", + description="Parse a git URL and detect if it's a browser URL that needs correction.", +) async def parse_repository_url(data: URLParseRequest) -> URLParseResponse: - """Parse a git URL and detect if it's a browser URL that needs correction.""" + """Parse a git URL and detect if it's a browser URL that needs correction. + + Args: + data: Request containing the URL to parse. + + Returns: + Parsed URL information including whether it needs parsing and suggested corrections. + """ result = parse_git_url(data.url) return URLParseResponse(**result) -@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "/{project_id}/repositories", + response_model=GitRepositoryResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a repository", + description="Create a new git repository in a project. Can clone from remote or initialize bare.", +) async def create_repository( project_id: uuid.UUID, data: GitRepositoryCreate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> GitRepository: + """Create a new git repository. + + Args: + project_id: UUID of the project. + data: Repository creation data including name and optional remote URL. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The newly created repository. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -229,7 +313,11 @@ async def create_repository( return repo -@router.get("/{project_id}/repositories/{repo_id}/history") +@router.get( + "/{project_id}/repositories/{repo_id}/history", + summary="Get repository history", + description="Get commit history for a repository with optional branch filtering.", +) async def get_repository_history( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -240,7 +328,21 @@ async def get_repository_history( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get commit history for a repository.""" + """Get commit history for a repository. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + view: View type for history display (default: graph). + branch: Optional branch name to filter commits. + limit: Maximum number of commits to return (default: 100). + offset: Number of commits to skip (default: 0). + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary containing commit history data. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -258,7 +360,11 @@ async def get_repository_history( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) -@router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}") +@router.get( + "/{project_id}/repositories/{repo_id}/commits/{commit_hash}", + summary="Get commit details", + description="Get detailed information about a specific commit.", +) async def get_repository_commit( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -266,7 +372,18 @@ async def get_repository_commit( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get detailed information about a specific commit.""" + """Get detailed information about a specific commit. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + commit_hash: Hash of the commit to retrieve. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary containing commit details. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -322,7 +439,12 @@ class FileUpdateResponse(BaseModel): branch: str -@router.get("/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse) +@router.get( + "/{project_id}/repositories/{repo_id}/files", + response_model=FileListResponse, + summary="List repository files", + description="List files and directories in a repository path.", +) async def list_repository_files( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -331,7 +453,19 @@ async def list_repository_files( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> FileListResponse: - """List files and directories in a repository path.""" + """List files and directories in a repository path. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + branch: Branch name to browse (default: main). + path: Directory path within the repository (default: root). + user_id: ID of the authenticated user. + session: Database session. + + Returns: + List of files and directories in the specified path. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -363,7 +497,12 @@ async def list_repository_files( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -@router.get("/{project_id}/repositories/{repo_id}/files/content", response_model=FileContentResponse) +@router.get( + "/{project_id}/repositories/{repo_id}/files/content", + response_model=FileContentResponse, + summary="Get file content", + description="Get the content of a file in a repository.", +) async def get_repository_file_content( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -372,7 +511,19 @@ async def get_repository_file_content( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> FileContentResponse: - """Get the content of a file.""" + """Get the content of a file. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + branch: Branch name where the file is located. + path: File path within the repository. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + File content and metadata. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -401,14 +552,29 @@ async def get_repository_file_content( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -@router.get("/{project_id}/repositories/{repo_id}/branches", response_model=BranchesResponse) +@router.get( + "/{project_id}/repositories/{repo_id}/branches", + response_model=BranchesResponse, + summary="List branches", + description="List all branches in the repository.", +) async def get_repository_branches( project_id: uuid.UUID, repo_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> BranchesResponse: - """List all branches in the repository.""" + """List all branches in the repository. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + List of branches and the default branch name. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -436,7 +602,12 @@ async def get_repository_branches( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -@router.post("/{project_id}/repositories/{repo_id}/files/content", response_model=FileUpdateResponse) +@router.post( + "/{project_id}/repositories/{repo_id}/files/content", + response_model=FileUpdateResponse, + summary="Update file content", + description="Update a file and create a commit.", +) async def update_repository_file( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -444,7 +615,18 @@ async def update_repository_file( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> FileUpdateResponse: - """Update a file and create a commit.""" + """Update a file and create a commit. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + data: File update data including path, branch, content, and commit message. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Commit information for the file update. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -493,14 +675,29 @@ class StatusResponse(BaseModel): behind: int -@router.get("/{project_id}/repositories/{repo_id}/status", response_model=StatusResponse) +@router.get( + "/{project_id}/repositories/{repo_id}/status", + response_model=StatusResponse, + summary="Get repository status", + description="Get the working directory status including modified, added, and deleted files.", +) async def get_repository_status( project_id: uuid.UUID, repo_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> StatusResponse: - """Get the working directory status.""" + """Get the working directory status. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Repository status including branch, modified files, and ahead/behind counts. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -536,7 +733,11 @@ class CheckoutRequest(BaseModel): branch: str -@router.post("/{project_id}/repositories/{repo_id}/branches") +@router.post( + "/{project_id}/repositories/{repo_id}/branches", + summary="Create a branch", + description="Create a new branch in the repository.", +) async def create_repository_branch( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -544,7 +745,18 @@ async def create_repository_branch( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Create a new branch.""" + """Create a new branch. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + data: Branch creation data including name and optional base branch. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary with success message and branch name. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -562,7 +774,11 @@ async def create_repository_branch( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -@router.delete("/{project_id}/repositories/{repo_id}/branches/{branch_name}") +@router.delete( + "/{project_id}/repositories/{repo_id}/branches/{branch_name}", + summary="Delete a branch", + description="Delete a branch from the repository.", +) async def delete_repository_branch( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -571,7 +787,19 @@ async def delete_repository_branch( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Delete a branch.""" + """Delete a branch. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + branch_name: Name of the branch to delete. + force: Whether to force delete the branch. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary with success message. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -589,7 +817,11 @@ async def delete_repository_branch( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -@router.post("/{project_id}/repositories/{repo_id}/checkout") +@router.post( + "/{project_id}/repositories/{repo_id}/checkout", + summary="Checkout a branch", + description="Checkout a branch in the repository.", +) async def checkout_repository_branch( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -597,7 +829,18 @@ async def checkout_repository_branch( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Checkout a branch.""" + """Checkout a branch. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + data: Checkout request containing the branch name. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary with success message and checked out branch name. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -625,7 +868,12 @@ class CommitResponse(BaseModel): message: str -@router.post("/{project_id}/repositories/{repo_id}/commit", response_model=CommitResponse) +@router.post( + "/{project_id}/repositories/{repo_id}/commit", + response_model=CommitResponse, + summary="Commit changes", + description="Commit changes to the repository.", +) async def commit_repository_changes( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -633,7 +881,18 @@ async def commit_repository_changes( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> CommitResponse: - """Commit changes to the repository.""" + """Commit changes to the repository. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + data: Commit request containing message and optional files to commit. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Commit information including hash and message. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -669,14 +928,29 @@ class FetchResponse(BaseModel): message: str -@router.post("/{project_id}/repositories/{repo_id}/fetch", response_model=FetchResponse) +@router.post( + "/{project_id}/repositories/{repo_id}/fetch", + response_model=FetchResponse, + summary="Fetch from remote", + description="Fetch updates from the remote repository.", +) async def fetch_repository( project_id: uuid.UUID, repo_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> FetchResponse: - """Fetch from remote.""" + """Fetch from remote. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Success message. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -698,7 +972,12 @@ class PullResponse(BaseModel): message: str -@router.post("/{project_id}/repositories/{repo_id}/pull", response_model=PullResponse) +@router.post( + "/{project_id}/repositories/{repo_id}/pull", + response_model=PullResponse, + summary="Pull from remote", + description="Pull updates from the remote repository.", +) async def pull_repository( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -706,7 +985,18 @@ async def pull_repository( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> PullResponse: - """Pull updates from remote.""" + """Pull updates from remote. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + branch: Optional branch name to pull. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Success message. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -728,7 +1018,12 @@ class PushResponse(BaseModel): message: str -@router.post("/{project_id}/repositories/{repo_id}/push", response_model=PushResponse) +@router.post( + "/{project_id}/repositories/{repo_id}/push", + response_model=PushResponse, + summary="Push to remote", + description="Push changes to the remote repository.", +) async def push_repository( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -736,7 +1031,18 @@ async def push_repository( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> PushResponse: - """Push changes to remote.""" + """Push changes to remote. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + branch: Optional branch name to push. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Success message. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -765,7 +1071,12 @@ class MergeResponse(BaseModel): message: str -@router.post("/{project_id}/repositories/{repo_id}/merge", response_model=MergeResponse) +@router.post( + "/{project_id}/repositories/{repo_id}/merge", + response_model=MergeResponse, + summary="Merge branches", + description="Merge one branch into another.", +) async def merge_repository_branches( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -773,7 +1084,18 @@ async def merge_repository_branches( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> MergeResponse: - """Merge branches.""" + """Merge branches. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + data: Merge request containing source branch, optional target branch, and message. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Merge result with commit hash and message. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) diff --git a/apps/api/src/api/health.py b/apps/api/src/api/health.py new file mode 100644 index 0000000..380112e --- /dev/null +++ b/apps/api/src/api/health.py @@ -0,0 +1,149 @@ +"""Health check endpoints and models.""" + +import time +from datetime import datetime, timezone +from typing import Any + +from fastapi import APIRouter, status +from pydantic import BaseModel, Field +from sqlalchemy import text + +from src.config import Settings +from src.database import SessionLocal + +router = APIRouter() + +# Track start time for uptime +_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, + summary="Health check", + description="Returns overall system health status including database and disk checks.", + tags=["Health"], +) +async def health_check() -> dict[str, Any]: + """Check overall system health. + + Returns: + HealthResponse with status, timestamp, version, checks, and uptime. + """ + checks = HealthChecks() + overall_status = "healthy" + + # Database check + try: + import time as time_module + + start = time_module.perf_counter() + async with SessionLocal() as session: + await session.execute(text("SELECT 1")) + db_time = (time_module.perf_counter() - start) * 1000 + checks.database = DatabaseHealth( + status="healthy", + response_time_ms=round(db_time, 2), + ) + except Exception: + checks.database = DatabaseHealth( + status="unhealthy", + response_time_ms=0.0, + ) + overall_status = "degraded" + + # Disk check + try: + import shutil + + disk = shutil.disk_usage("/") + free_gb = disk.free / (1024**3) + total_gb = disk.total / (1024**3) + disk_status = "healthy" if free_gb > 1.0 else "degraded" + if disk_status == "degraded": + overall_status = "degraded" + checks.disk = DiskHealth( + status=disk_status, + free_gb=round(free_gb, 2), + total_gb=round(total_gb, 2), + ) + except Exception: + checks.disk = None + + return HealthResponse( + status=overall_status, + timestamp=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + version="0.1.0", + checks=checks, + uptime_seconds=round(time.time() - _start_time, 2), + ).model_dump() + + +@router.get( + "/health/db", + response_model=DatabaseHealthResponse, + summary="Database health check", + description="Returns database-specific health status with response time.", + tags=["Health"], +) +async def health_check_db() -> dict[str, Any]: + """Check database health. + + Returns: + DatabaseHealthResponse with status and response time. + """ + import time as time_module + + try: + start = time_module.perf_counter() + async with SessionLocal() as session: + await session.execute(text("SELECT 1")) + db_time = (time_module.perf_counter() - start) * 1000 + return DatabaseHealthResponse( + status="healthy", + response_time_ms=round(db_time, 2), + ).model_dump() + except Exception: + return DatabaseHealthResponse( + status="unhealthy", + response_time_ms=0.0, + ).model_dump() diff --git a/apps/api/src/api/projects.py b/apps/api/src/api/projects.py index 664b22b..a9d1056 100644 --- a/apps/api/src/api/projects.py +++ b/apps/api/src/api/projects.py @@ -17,6 +17,7 @@ router = APIRouter(prefix="/projects", tags=["projects"]) async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: + """Fetch a user by ID or raise 401 if not found.""" user = await session.get(User, user_id) if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") @@ -47,12 +48,28 @@ class SetDefaultSSHKeyRequest(BaseModel): ssh_key_id: uuid.UUID -@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "", + response_model=ProjectResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a new project", + description="Create a new project for the authenticated user.", +) async def create_project( data: ProjectCreate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> Project: + """Create a new project. + + Args: + data: Project creation data including name and optional description. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The newly created project. + """ user = await _get_user(session, user_id) project = Project( name=data.name, @@ -66,22 +83,51 @@ async def create_project( return project -@router.get("", response_model=list[ProjectResponse]) +@router.get( + "", + response_model=list[ProjectResponse], + summary="List all projects", + description="Retrieve all projects owned by the authenticated user.", +) async def list_projects( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> list[Project]: + """List all projects for the authenticated user. + + Args: + user_id: ID of the authenticated user. + session: Database session. + + Returns: + List of projects owned by the user. + """ user = await _get_user(session, user_id) result = await session.execute(select(Project).where(Project.owner_id == user.id)) return list(result.scalars().all()) -@router.get("/{project_id}", response_model=ProjectResponse) +@router.get( + "/{project_id}", + response_model=ProjectResponse, + summary="Get a project", + description="Retrieve a specific project by ID.", +) async def get_project( project_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> Project: + """Get a specific project by ID. + + Args: + project_id: UUID of the project to retrieve. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The requested project. + """ await _get_user(session, user_id) return await _get_owned_project(project_id, user_id, session) @@ -91,6 +137,19 @@ async def _get_owned_project( user_id: uuid.UUID, session: AsyncSession, ) -> Project: + """Fetch a project and verify ownership. + + Args: + project_id: UUID of the project. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The project if found and owned by the user. + + Raises: + HTTPException: If project not found or user is not the owner. + """ project = await session.get(Project, project_id) if project is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found") @@ -99,13 +158,29 @@ async def _get_owned_project( return project -@router.patch("/{project_id}", response_model=ProjectResponse) +@router.patch( + "/{project_id}", + response_model=ProjectResponse, + summary="Update a project", + description="Update a project's name or description.", +) async def update_project( project_id: uuid.UUID, data: ProjectUpdate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> Project: + """Update a project. + + Args: + project_id: UUID of the project to update. + data: Project update data with optional name and description. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The updated project. + """ await _get_user(session, user_id) project = await _get_owned_project(project_id, user_id, session) @@ -119,12 +194,27 @@ async def update_project( return project -@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete( + "/{project_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a project", + description="Delete a project and all its associated repositories.", +) async def delete_project( project_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> Response: + """Delete a project and all its repositories. + + Args: + project_id: UUID of the project to delete. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Empty response with 204 status code. + """ await _get_user(session, user_id) project = await _get_owned_project(project_id, user_id, session) @@ -141,13 +231,29 @@ async def delete_project( return Response(status_code=status.HTTP_204_NO_CONTENT) -@router.patch("/{project_id}/default-ssh-key", response_model=ProjectResponse) +@router.patch( + "/{project_id}/default-ssh-key", + response_model=ProjectResponse, + summary="Set default SSH key", + description="Set the default SSH key for a project.", +) async def set_default_ssh_key( project_id: uuid.UUID, data: SetDefaultSSHKeyRequest, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> Project: + """Set the default SSH key for a project. + + Args: + project_id: UUID of the project. + data: Request containing the SSH key ID to set as default. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The updated project. + """ user = await _get_user(session, user_id) project = await _get_owned_project(project_id, user_id, session) diff --git a/apps/api/src/api/ssh_keys.py b/apps/api/src/api/ssh_keys.py index 1ea68b6..4a58806 100644 --- a/apps/api/src/api/ssh_keys.py +++ b/apps/api/src/api/ssh_keys.py @@ -18,6 +18,7 @@ router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"]) async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: + """Fetch a user by ID or raise 401 if not found.""" user = await session.get(User, user_id) if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") @@ -38,6 +39,11 @@ def _get_fernet() -> Fernet: def generate_ssh_key_pair() -> tuple[str, str]: + """Generate a new Ed25519 SSH key pair. + + Returns: + Tuple of (private_key, public_key) as strings. + """ private_key = Ed25519PrivateKey.generate() public_key = private_key.public_key() @@ -68,12 +74,28 @@ class SSHKeyResponse(BaseModel): created_at: datetime -@router.post("", response_model=SSHKeyResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "", + response_model=SSHKeyResponse, + status_code=status.HTTP_201_CREATED, + summary="Create SSH key", + description="Generate a new Ed25519 SSH key pair for the authenticated user.", +) async def create_ssh_key( data: SSHKeyCreate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> SSHKey: + """Create a new SSH key pair. + + Args: + data: SSH key creation data including the key name. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The newly created SSH key with public key exposed. + """ user = await _get_user(session, user_id) private_key, public_key = generate_ssh_key_pair() @@ -92,22 +114,51 @@ async def create_ssh_key( return ssh_key -@router.get("", response_model=list[SSHKeyResponse]) +@router.get( + "", + response_model=list[SSHKeyResponse], + summary="List SSH keys", + description="List all SSH keys for the authenticated user.", +) async def list_ssh_keys( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> list[SSHKey]: + """List all SSH keys for the authenticated user. + + Args: + user_id: ID of the authenticated user. + session: Database session. + + Returns: + List of SSH keys owned by the user. + """ user = await _get_user(session, user_id) result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id)) return list(result.scalars().all()) -@router.delete("/{key_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete( + "/{key_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete SSH key", + description="Delete an SSH key by ID.", +) async def delete_ssh_key( key_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> None: + """Delete an SSH key. + + Args: + key_id: UUID of the SSH key to delete. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + None with 204 status code. + """ 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: diff --git a/apps/api/src/api/terminal.py b/apps/api/src/api/terminal.py index 403b200..dbd386d 100644 --- a/apps/api/src/api/terminal.py +++ b/apps/api/src/api/terminal.py @@ -13,13 +13,26 @@ from src.services.terminal_manager import terminal_manager router = APIRouter() -@router.websocket("/ws/tool-instances/{instance_id}/terminal") +@router.websocket( + "/ws/tool-instances/{instance_id}/terminal", +) async def terminal_websocket( websocket: WebSocket, instance_id: str, db_session: AsyncSession = Depends(get_db_session), ) -> None: - """WebSocket endpoint for terminal access to a tool instance.""" + """WebSocket endpoint for terminal access to a tool instance. + + Provides an interactive terminal session inside a running tool instance container. + + Args: + websocket: The WebSocket connection. + instance_id: UUID string of the tool instance. + db_session: Database session. + + Returns: + None. Communicates via WebSocket messages. + """ await websocket.accept() try: @@ -82,7 +95,15 @@ async def _get_user_from_websocket( websocket: WebSocket, db_session: AsyncSession, ) -> uuid.UUID | None: - """Extract and validate user ID from session cookie in WebSocket.""" + """Extract and validate user ID from session cookie in WebSocket. + + Args: + websocket: The WebSocket connection. + db_session: Database session. + + Returns: + The user's UUID if authenticated, None otherwise. + """ from src.auth.session import verify_session_token session_cookie = websocket.cookies.get("session") diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index e31cf19..440d61f 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -30,6 +30,7 @@ router = APIRouter(prefix="/projects", tags=["tool-instances"]) async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: + """Fetch a user by ID or raise 404 if not found.""" user = await session.get(User, user_id) if user is None: raise HTTPException( @@ -41,6 +42,19 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: async def _get_owned_project( project_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession ) -> Project: + """Fetch a project and verify ownership. + + Args: + project_id: UUID of the project. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The project if found and owned by the user. + + Raises: + HTTPException: If project not found or user is not the owner. + """ project = await session.get(Project, project_id) if project is None or project.owner_id != user_id: raise HTTPException( @@ -49,7 +63,11 @@ async def _get_owned_project( return project -@router.post("/{project_id}/repositories/{repo_id}/instances") +@router.post( + "/{project_id}/repositories/{repo_id}/instances", + summary="Create tool instance", + description="Create a new tool instance for a repository.", +) async def create_instance( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -58,7 +76,19 @@ async def create_instance( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Create a new tool instance for a repository.""" + """Create a new tool instance for a repository. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + tool_type_id: UUID of the tool type to instantiate. + display_name: Optional display name for the instance. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary with instance details. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -123,14 +153,28 @@ async def create_instance( } -@router.get("/{project_id}/repositories/{repo_id}/instances") +@router.get( + "/{project_id}/repositories/{repo_id}/instances", + summary="List instances", + description="List all tool instances for a repository.", +) async def list_instances( project_id: uuid.UUID, repo_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """List all instances for a repository.""" + """List all instances for a repository. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary containing list of instances. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -165,7 +209,11 @@ async def list_instances( } -@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}") +@router.get( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}", + summary="Get instance", + description="Get a specific instance with real-time status from Docker.", +) async def get_instance( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -173,7 +221,18 @@ async def get_instance( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get a specific instance with real-time status.""" + """Get a specific instance with real-time status. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the instance. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary with instance details and current status. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -210,7 +269,11 @@ async def get_instance( } -@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/start") +@router.post( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}/start", + summary="Start instance", + description="Start a tool instance using Docker Compose.", +) async def start_instance( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -218,7 +281,18 @@ async def start_instance( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Start a tool instance.""" + """Start a tool instance. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the instance to start. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary with status and URL of the running instance. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -262,7 +336,11 @@ async def start_instance( return {"status": instance.status, "url": instance.url} -@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop") +@router.post( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}/stop", + summary="Stop instance", + description="Stop a running tool instance.", +) async def stop_instance( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -270,7 +348,18 @@ async def stop_instance( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Stop a tool instance.""" + """Stop a tool instance. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the instance to stop. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary with the stopped status. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -291,7 +380,11 @@ async def stop_instance( return {"status": instance.status} -@router.post("/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart") +@router.post( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}/restart", + summary="Restart instance", + description="Restart a tool instance.", +) async def restart_instance( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -299,7 +392,18 @@ async def restart_instance( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Restart a tool instance.""" + """Restart a tool instance. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the instance to restart. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary with status and URL of the restarted instance. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -326,7 +430,11 @@ async def restart_instance( return {"status": instance.status} -@router.delete("/{project_id}/repositories/{repo_id}/instances/{instance_id}") +@router.delete( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}", + summary="Delete instance", + description="Delete a tool instance and remove its Docker containers and files.", +) async def delete_instance( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -334,7 +442,18 @@ async def delete_instance( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> None: - """Delete a tool instance.""" + """Delete a tool instance. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the instance to delete. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + None with 204 status code. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -359,7 +478,11 @@ async def delete_instance( await session.commit() -@router.get("/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs") +@router.get( + "/{project_id}/repositories/{repo_id}/instances/{instance_id}/logs", + summary="Get instance logs", + description="Get container logs for a tool instance.", +) async def get_instance_logs( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -368,7 +491,19 @@ async def get_instance_logs( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get container logs for an instance.""" + """Get container logs for an instance. + + Args: + project_id: UUID of the project. + repo_id: UUID of the repository. + instance_id: UUID of the instance. + tail: Number of log lines to return (default: 100). + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary containing the container logs. + """ _user = await _get_user(session, user_id) _project = await _get_owned_project(project_id, user_id, session) @@ -389,12 +524,24 @@ from fastapi import APIRouter as FastAPIRouter sessions_router = FastAPIRouter(prefix="/users", tags=["sessions"]) -@sessions_router.get("/me/sessions") +@sessions_router.get( + "/me/sessions", + summary="Get user sessions", + description="Get all active sessions (running instances) for the current user.", +) async def get_user_sessions( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get all active sessions (running instances) for the current user.""" + """Get all active sessions for the current user. + + Args: + user_id: ID of the authenticated user. + session: Database session. + + Returns: + Dictionary containing list of active sessions with instance details. + """ _user = await _get_user(session, user_id) result = await session.execute( diff --git a/apps/api/src/api/tool_types.py b/apps/api/src/api/tool_types.py index 25a3f59..10136ce 100644 --- a/apps/api/src/api/tool_types.py +++ b/apps/api/src/api/tool_types.py @@ -15,6 +15,7 @@ router = APIRouter(prefix="/tool-types", tags=["tool-types"]) async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: + """Fetch a user by ID or raise 401 if not found.""" user = await session.get(User, user_id) if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") @@ -22,6 +23,11 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: async def _require_admin(user: User) -> None: + """Check if user has admin privileges. + + For now, all authenticated users can manage tool types. + In production, this should check user.role or similar. + """ # For now, all authenticated users can manage tool types # In production, check user.role or similar pass @@ -117,12 +123,28 @@ class ToolTypeResponse(BaseModel): updated_at: datetime -@router.post("", response_model=ToolTypeResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "", + response_model=ToolTypeResponse, + status_code=status.HTTP_201_CREATED, + summary="Create tool type", + description="Create a new custom tool type with a Docker Compose template.", +) async def create_tool_type( data: ToolTypeCreate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> ToolType: + """Create a new tool type. + + Args: + data: Tool type creation data including name, display name, and compose template. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The newly created tool type. + """ user = await _get_user(session, user_id) await _require_admin(user) @@ -146,22 +168,51 @@ async def create_tool_type( return tool_type -@router.get("", response_model=list[ToolTypeResponse]) +@router.get( + "", + response_model=list[ToolTypeResponse], + summary="List tool types", + description="List all available tool types including built-in and custom ones.", +) async def list_tool_types( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> list[ToolType]: + """List all tool types. + + Args: + user_id: ID of the authenticated user. + session: Database session. + + Returns: + List of all tool types ordered by name. + """ await _get_user(session, user_id) result = await session.execute(select(ToolType).order_by(ToolType.name)) return list(result.scalars().all()) -@router.get("/{tool_type_id}", response_model=ToolTypeResponse) +@router.get( + "/{tool_type_id}", + response_model=ToolTypeResponse, + summary="Get tool type", + description="Get a specific tool type by ID.", +) async def get_tool_type( tool_type_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> ToolType: + """Get a specific tool type by ID. + + Args: + tool_type_id: UUID of the tool type to retrieve. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The requested tool type. + """ await _get_user(session, user_id) tool_type = await session.get(ToolType, tool_type_id) if tool_type is None: @@ -169,13 +220,29 @@ async def get_tool_type( return tool_type -@router.put("/{tool_type_id}", response_model=ToolTypeResponse) +@router.put( + "/{tool_type_id}", + response_model=ToolTypeResponse, + summary="Update tool type", + description="Update a custom tool type. Built-in tool types cannot be modified.", +) async def update_tool_type( tool_type_id: uuid.UUID, data: ToolTypeUpdate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> ToolType: + """Update a tool type. + + Args: + tool_type_id: UUID of the tool type to update. + data: Tool type update data with optional fields. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The updated tool type. + """ user = await _get_user(session, user_id) await _require_admin(user) @@ -217,12 +284,27 @@ async def update_tool_type( return tool_type -@router.delete("/{tool_type_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete( + "/{tool_type_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete tool type", + description="Delete a custom tool type. Built-in tool types cannot be deleted.", +) async def delete_tool_type( tool_type_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> None: + """Delete a tool type. + + Args: + tool_type_id: UUID of the tool type to delete. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + None with 204 status code. + """ user = await _get_user(session, user_id) await _require_admin(user) diff --git a/apps/api/src/api/user_config.py b/apps/api/src/api/user_config.py index 95d4308..c458a8e 100644 --- a/apps/api/src/api/user_config.py +++ b/apps/api/src/api/user_config.py @@ -13,6 +13,7 @@ router = APIRouter(prefix="/users/me", tags=["user-config"]) async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: + """Fetch a user by ID or raise 401 if not found.""" user = await session.get(User, user_id) if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") @@ -20,6 +21,15 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig: + """Get or create user config record. + + Args: + session: Database session. + user_id: UUID of the user. + + Returns: + The user's config, creating a new one if it doesn't exist. + """ result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) config = result.scalar_one_or_none() if config is None: @@ -46,22 +56,51 @@ class UserConfigUpdate(BaseModel): git_user_email: str | None = None -@router.get("/config", response_model=UserConfigResponse) +@router.get( + "/config", + response_model=UserConfigResponse, + summary="Get user config", + description="Get the current user's configuration settings.", +) async def get_user_config( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> UserConfigResponse: + """Get the current user's configuration. + + Args: + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The user's configuration settings. + """ _user = await _get_user(session, user_id) config = await _get_or_create_config(session, user_id) return UserConfigResponse.model_validate(config.config) -@router.patch("/config", response_model=UserConfigResponse) +@router.patch( + "/config", + response_model=UserConfigResponse, + summary="Update user config", + description="Update the current user's configuration settings.", +) async def update_user_config( data: UserConfigUpdate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> UserConfigResponse: + """Update the current user's configuration. + + Args: + data: Configuration update data with optional fields. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The updated user configuration. + """ _user = await _get_user(session, user_id) config = await _get_or_create_config(session, user_id) diff --git a/apps/api/src/api/users.py b/apps/api/src/api/users.py index 8d81319..65a49c9 100644 --- a/apps/api/src/api/users.py +++ b/apps/api/src/api/users.py @@ -17,6 +17,7 @@ MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User: + """Fetch a user by ID or raise 401 if not found.""" user = await session.get(User, user_id) if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") @@ -37,20 +38,49 @@ class UserProfileUpdate(BaseModel): email: str | None = None -@router.get("/me", response_model=UserProfileResponse) +@router.get( + "/me", + response_model=UserProfileResponse, + summary="Get current user profile", + description="Retrieve the profile of the currently authenticated user.", +) async def get_profile( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> User: + """Get the current user's profile. + + Args: + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The user's profile information. + """ return await _get_user(session, user_id) -@router.put("/me", response_model=UserProfileResponse) +@router.put( + "/me", + response_model=UserProfileResponse, + summary="Update user profile", + description="Update the current user's profile information.", +) async def update_profile( data: UserProfileUpdate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> User: + """Update the current user's profile. + + Args: + data: Profile update data with optional name and email. + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The updated user profile. + """ user = await _get_user(session, user_id) if data.name is not None: @@ -68,12 +98,27 @@ async def update_profile( return user -@router.post("/me/avatar", response_model=UserProfileResponse) +@router.post( + "/me/avatar", + response_model=UserProfileResponse, + summary="Upload avatar", + description="Upload a profile avatar image (PNG or JPG, max 2MB).", +) async def upload_avatar( file: UploadFile, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> User: + """Upload a profile avatar image. + + Args: + file: The image file to upload (PNG or JPG, max 2MB). + user_id: ID of the authenticated user. + session: Database session. + + Returns: + The updated user profile with new avatar URL. + """ user = await _get_user(session, user_id) if file.content_type not in ALLOWED_CONTENT_TYPES: diff --git a/apps/api/src/main.py b/apps/api/src/main.py index d262870..f5ca7dc 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -9,6 +9,7 @@ from sqlalchemy import select, text from src.api.auth import router as auth_router from src.api.dashboard import router as dashboard_router from src.api.git_repositories import router as git_repositories_router +from src.api.health import router as health_router from src.api.projects import router as projects_router from src.api.ssh_keys import router as ssh_keys_router from src.api.terminal import router as terminal_router @@ -148,17 +149,7 @@ async def on_startup(): await seed_builtin_tool_types() logger.info("Startup complete.") -@app.get("/health") -async def health_check(): - try: - from sqlalchemy import text - async with SessionLocal() as session: - await session.execute(text("SELECT 1")) - return {"status": "healthy", "database": "connected"} - except Exception as exc: - logger.error("Health check failed: %s", exc) - return {"status": "unhealthy", "database": "disconnected", "error": str(exc)} - +app.include_router(health_router) app.include_router(auth_router) app.include_router(dashboard_router) app.include_router(projects_router) diff --git a/docs/architecture/decisions/adr-001-session-auth.md b/docs/architecture/decisions/adr-001-session-auth.md new file mode 100644 index 0000000..4e8a607 --- /dev/null +++ b/docs/architecture/decisions/adr-001-session-auth.md @@ -0,0 +1,68 @@ +# ADR-001: Session-Based Authentication with httpOnly Cookies + +## Status + +Accepted + +## Context + +The application needs an authentication mechanism for the OAuth2 flow with Authentik. We need to decide between: + +1. **JWT tokens** stored in localStorage (common SPA pattern) +2. **Session cookies** with httpOnly flag +3. **JWT tokens** in httpOnly cookies + +### Constraints + +- Frontend and API run on different subdomains in production (e.g., `app.example.com` and `api.example.com`) +- Must support OAuth2 authorization code flow +- Must work with Traefik reverse proxy +- Must be secure against XSS attacks + +## Decision + +We will use **session-based authentication with HMAC-signed httpOnly cookies**. + +### Implementation + +1. After OAuth callback, the API creates a session token (HMAC-SHA256 signed) +2. Token is stored in an httpOnly, Secure, SameSite cookie +3. Frontend never sees or stores the token +4. Cookie is sent automatically with every request via `withCredentials: true` +5. Session is stateless - token contains user_id and expiry + +## Consequences + +### Positive + +- **XSS protection**: Token is never exposed to JavaScript +- **Simpler frontend**: No token management, refresh logic, or storage +- **Immediate revocation**: Can invalidate sessions server-side if needed +- **Standards compliant**: Uses well-established cookie security mechanisms +- **Works across subdomains**: Cookie domain can be set to parent domain + +### Negative + +- **CSRF risk**: Requires CSRF protection for state-changing operations (mitigated by SameSite=Lax/Strict) +- **Less flexible**: Harder to use with non-browser clients (mitigated by API key support if needed) +- **Cookie size**: Session token adds ~100 bytes to every request + +### Alternatives Considered + +**JWT in localStorage** +- Pros: Simple implementation, works with any client +- Cons: Vulnerable to XSS, requires manual token refresh, complex frontend logic +- Rejected due to XSS vulnerability + +**JWT in httpOnly cookies** +- Pros: XSS protection, standard JWT benefits +- Cons: Complex refresh token rotation, no easy revocation, larger token size +- Rejected in favor of simpler session cookies + +## Date + +2026-05-18 + +## Participants + +- Development Team diff --git a/docs/architecture/decisions/adr-002-async-sqlalchemy.md b/docs/architecture/decisions/adr-002-async-sqlalchemy.md new file mode 100644 index 0000000..08b377b --- /dev/null +++ b/docs/architecture/decisions/adr-002-async-sqlalchemy.md @@ -0,0 +1,70 @@ +# ADR-002: Async SQLAlchemy with PostgreSQL + +## Status + +Accepted + +## Context + +We need to choose an ORM and database for the application. The application will handle concurrent requests and potentially long-running operations (git operations, Docker commands). + +### Requirements + +- Support concurrent API requests without blocking +- Handle async operations (database queries + subprocess calls) +- Type safety and autocompletion +- Migration support +- Good Python ecosystem support + +## Decision + +We will use **SQLAlchemy 2.0 with async PostgreSQL** via `asyncpg`. + +### Implementation + +1. **SQLAlchemy 2.0** with `AsyncSession` and declarative models +2. **PostgreSQL** as the primary database +3. **asyncpg** as the async driver +4. **Alembic** for database migrations + +## Consequences + +### Positive + +- **Non-blocking I/O**: Database queries don't block the event loop +- **Scalability**: Can handle many concurrent connections +- **Type safety**: SQLAlchemy 2.0 has excellent type hint support +- **Ecosystem**: Large community, extensive documentation +- **Flexibility**: Can fall back to sync operations for complex migrations + +### Negative + +- **Complexity**: Async SQLAlchemy has a steeper learning curve +- **Debugging**: Harder to debug async code +- **Migration limitations**: Some Alembic operations require sync connections +- **Connection pool**: Requires careful configuration + +### Alternatives Considered + +**Prisma ORM** +- Pros: Modern, type-safe, auto-generated client +- Cons: Less mature Python support, custom query language +- Rejected due to less mature ecosystem + +**Tortoise ORM** +- Pros: Built for async, Django-like syntax +- Cons: Smaller community, fewer features +- Rejected in favor of SQLAlchemy's maturity + +**Sync SQLAlchemy with threading** +- Pros: Simpler, well-understood +- Cons: Thread overhead, harder to integrate with async code +- Rejected in favor of native async support + +## Date + +2026-05-17 + +## Participants + +- Development Team diff --git a/openspec/changes/api-documentation/.openspec/config.yaml b/openspec/changes/api-documentation/.openspec/config.yaml new file mode 100644 index 0000000..8964622 --- /dev/null +++ b/openspec/changes/api-documentation/.openspec/config.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +name: api-documentation diff --git a/openspec/changes/api-documentation/design.md b/openspec/changes/api-documentation/design.md new file mode 100644 index 0000000..4b34040 --- /dev/null +++ b/openspec/changes/api-documentation/design.md @@ -0,0 +1,116 @@ +# API Documentation - Design + +## Architecture + +``` +API Documentation +├── OpenAPI (FastAPI native) +│ ├── /docs (Swagger UI) +│ ├── /redoc (ReDoc) +│ └── /openapi.json +├── Health Endpoints +│ ├── GET /health +│ └── GET /health/db +├── API README +│ └── apps/api/README.md +└── Architecture Decision Records + └── docs/architecture/decisions/ +``` + +## Component Design + +### OpenAPI Documentation + +FastAPI automatically generates OpenAPI schema from: +- Pydantic models (request/response) +- Endpoint docstrings +- Path operation parameters +- Response status codes + +**Enhancements needed:** +- Add descriptions to all Pydantic models +- Add docstrings to all endpoints +- Add response examples where helpful +- Add authentication requirements + +### Health Endpoints + +**GET /health** +```json +{ + "status": "healthy", + "timestamp": "2026-05-19T12:00:00Z", + "version": "0.1.0", + "checks": { + "database": { + "status": "healthy", + "response_time_ms": 5.2 + }, + "disk": { + "status": "healthy", + "free_gb": 45.2, + "total_gb": 100.0 + } + }, + "uptime_seconds": 3600 +} +``` + +**GET /health/db** +```json +{ + "status": "healthy", + "response_time_ms": 5.2, + "connections": { + "active": 2, + "idle": 3, + "max": 20 + } +} +``` + +### API README + +**Sections:** +1. Overview +2. Quick Start +3. Environment Variables +4. Development Setup +5. Running Tests +6. Architecture Overview +7. Common Commands +8. Deployment + +### Architecture Decision Records + +**Format:** +- Title +- Status (proposed, accepted, deprecated) +- Context +- Decision +- Consequences +- Date + +**ADRs to create:** +1. Session-based authentication (vs JWT) +2. SQLAlchemy async with PostgreSQL +3. Docker-based tool instances + +## Technical Details + +**Libraries:** +- FastAPI (built-in OpenAPI) +- Pydantic v2 (schemas) +- psutil (disk/health metrics) + +**Files to modify:** +- `apps/api/src/main.py` - Add health endpoints +- All `apps/api/src/api/*.py` - Add docstrings +- All `apps/api/src/models/*.py` - Add model descriptions +- `apps/api/src/schemas/*.py` - Add response schemas + +## Error Handling + +- Health endpoints return 200 even if degraded +- Failed checks included in response with "degraded" status +- Never expose sensitive info in health responses diff --git a/openspec/changes/api-documentation/proposal.md b/openspec/changes/api-documentation/proposal.md new file mode 100644 index 0000000..a4d2990 --- /dev/null +++ b/openspec/changes/api-documentation/proposal.md @@ -0,0 +1,54 @@ +# API Documentation + +## Problem + +The API lacks comprehensive documentation: +- No auto-generated OpenAPI/Swagger UI +- Health endpoint is minimal (only checks database) +- Missing endpoint documentation and docstrings +- No API README for developer onboarding +- No Architecture Decision Records (ADRs) + +## Solution + +Provide comprehensive API documentation: + +1. **Auto-generated OpenAPI docs** - FastAPI native `/docs` and `/redoc` endpoints +2. **Enhanced health checks** - `/health` with full system status, `/health/db` for database +3. **Endpoint documentation** - Proper docstrings, Pydantic models, response codes +4. **API README** - Developer onboarding guide +5. **Architecture Decision Records** - Document key architectural choices + +## Key Features + +### OpenAPI Documentation +- Interactive Swagger UI at `/docs` +- ReDoc at `/redoc` +- All endpoints with schemas and examples +- Authentication documented + +### Health Monitoring +- `/health` - Overall system health (database, disk, uptime) +- `/health/db` - Database-specific health check +- JSON responses with status indicators + +### Developer Documentation +- `apps/api/README.md` - Setup, env vars, testing +- ADRs in `docs/architecture/decisions/` +- Inline code documentation + +## Benefits + +- **Developer onboarding** - New devs can understand the API quickly +- **API discoverability** - Interactive docs for testing endpoints +- **Health monitoring** - Operations can monitor system health +- **Knowledge preservation** - ADRs capture why decisions were made + +## Success Criteria + +- [ ] `/docs` loads Swagger UI with all endpoints +- [ ] `/health` returns comprehensive health data +- [ ] `/health/db` returns database status +- [ ] All endpoints have docstrings +- [ ] API README exists with setup instructions +- [ ] At least one ADR exists diff --git a/openspec/changes/api-documentation/specs/spec.md b/openspec/changes/api-documentation/specs/spec.md new file mode 100644 index 0000000..b83edae --- /dev/null +++ b/openspec/changes/api-documentation/specs/spec.md @@ -0,0 +1,125 @@ +# API Documentation Specification + +## Requirements + +### Functional Requirements + +1. **OpenAPI Documentation**: Auto-generated at `/docs` and `/redoc` +2. **Health Endpoints**: `/health` and `/health/db` with comprehensive status +3. **Endpoint Documentation**: All endpoints have docstrings and Pydantic models +4. **API README**: `apps/api/README.md` with developer onboarding +5. **Architecture Decision Records**: Document key architectural choices + +### Non-Functional Requirements + +1. **Performance**: Health checks complete in < 100ms +2. **Security**: Health endpoints don't expose sensitive data +3. **Maintainability**: Documentation stays in sync with code + +## API Specification + +### GET /health + +Returns overall system health status. + +**Response 200:** +```json +{ + "status": "healthy", + "timestamp": "2026-05-19T12:00:00Z", + "version": "0.1.0", + "checks": { + "database": { + "status": "healthy", + "response_time_ms": 5.2 + }, + "disk": { + "status": "healthy", + "free_gb": 45.2, + "total_gb": 100.0 + } + }, + "uptime_seconds": 3600 +} +``` + +### GET /health/db + +Returns database-specific health status. + +**Response 200:** +```json +{ + "status": "healthy", + "response_time_ms": 5.2 +} +``` + +### GET /docs + +FastAPI Swagger UI (auto-generated). + +### GET /redoc + +FastAPI ReDoc (auto-generated). + +## Documentation Requirements + +### Endpoint Docstrings + +Every endpoint must have: +- Description of what it does +- Request/response model descriptions +- Authentication requirements +- Error responses + +### Pydantic Models + +Every model must have: +- `description` field metadata +- Example values where helpful +- Proper typing + +### API README Structure + +```markdown +# Headquarter API + +## Overview +## Quick Start +## Environment Variables +## Development +## Testing +## Architecture +## Deployment +``` + +## ADR Template + +```markdown +# ADR-XXX: Title + +## Status +Accepted + +## Context +What is the issue we're facing? + +## Decision +What did we decide? + +## Consequences +What are the trade-offs? + +## Date +2026-05-19 +``` + +## Quality Gates + +- `/docs` loads successfully +- `/health` returns 200 with valid JSON +- `/health/db` returns database status +- All endpoints have docstrings +- API README is complete +- At least one ADR exists diff --git a/openspec/changes/api-documentation/tasks.md b/openspec/changes/api-documentation/tasks.md new file mode 100644 index 0000000..f8262f0 --- /dev/null +++ b/openspec/changes/api-documentation/tasks.md @@ -0,0 +1,94 @@ +# API Documentation - Tasks + +## Phase 1: Health Endpoints + +- [ ] **Task 1.1**: Enhance `/health` endpoint + - Add timestamp, version, uptime + - Add disk space check + - Add comprehensive checks object + - Create HealthCheck Pydantic models + +- [ ] **Task 1.2**: Create `/health/db` endpoint + - Database connection check + - Response time measurement + - Connection pool status + +## Phase 2: Endpoint Documentation + +- [ ] **Task 2.1**: Document auth endpoints + - Add docstrings to `src/api/auth.py` + - Add response model descriptions + - Add error responses + +- [ ] **Task 2.2**: Document projects endpoints + - Add docstrings to `src/api/projects.py` + - Document request/response models + +- [ ] **Task 2.3**: Document repositories endpoints + - Add docstrings to `src/api/git_repositories.py` + - Document file operations + +- [ ] **Task 2.4**: Document user endpoints + - Add docstrings to `src/api/users.py` + - Document profile endpoints + +- [ ] **Task 2.5**: Document tool endpoints + - Add docstrings to `src/api/tool_types.py` + - Add docstrings to `src/api/tool_instances.py` + +- [ ] **Task 2.6**: Document SSH keys endpoints + - Add docstrings to `src/api/ssh_keys.py` + +- [ ] **Task 2.7**: Document config endpoints + - Add docstrings to `src/api/user_config.py` + +- [ ] **Task 2.8**: Document dashboard endpoint + - Add docstrings to `src/api/dashboard.py` + +- [ ] **Task 2.9**: Document terminal endpoint + - Add docstrings to `src/api/terminal.py` + +## Phase 3: Model Documentation + +- [ ] **Task 3.1**: Document Pydantic models + - Add descriptions to all response models + - Add example values + - Document in `src/schemas/` or inline + +## Phase 4: API README + +- [ ] **Task 4.1**: Create `apps/api/README.md` + - Overview section + - Quick start guide + - Environment variables table + - Development setup + - Testing instructions + - Architecture overview + - Common commands + +## Phase 5: Architecture Decision Records + +- [ ] **Task 5.1**: Create ADR for session auth + - Document why cookies vs JWT + - Trade-offs and risks + +- [ ] **Task 5.2**: Create ADR for async SQLAlchemy + - Document async pattern choice + - PostgreSQL decision + +## Phase 6: Quality Gates + +- [ ] **Task 6.1**: Verify `/docs` endpoint + - Check all endpoints appear + - Verify schemas are documented + +- [ ] **Task 6.2**: Verify health endpoints + - Test `/health` + - Test `/health/db` + +- [ ] **Task 6.3**: Run linting + - ruff check + - mypy + +- [ ] **Task 6.4**: Run tests + - pytest diff --git a/openspec/changes/tool-terminal/.openspec/config.yaml b/openspec/changes/archive/2026-05-19-tool-terminal/.openspec/config.yaml similarity index 100% rename from openspec/changes/tool-terminal/.openspec/config.yaml rename to openspec/changes/archive/2026-05-19-tool-terminal/.openspec/config.yaml diff --git a/openspec/changes/tool-terminal/design.md b/openspec/changes/archive/2026-05-19-tool-terminal/design.md similarity index 100% rename from openspec/changes/tool-terminal/design.md rename to openspec/changes/archive/2026-05-19-tool-terminal/design.md diff --git a/openspec/changes/tool-terminal/proposal.md b/openspec/changes/archive/2026-05-19-tool-terminal/proposal.md similarity index 100% rename from openspec/changes/tool-terminal/proposal.md rename to openspec/changes/archive/2026-05-19-tool-terminal/proposal.md diff --git a/openspec/changes/tool-terminal/specs/spec.md b/openspec/changes/archive/2026-05-19-tool-terminal/specs/spec.md similarity index 100% rename from openspec/changes/tool-terminal/specs/spec.md rename to openspec/changes/archive/2026-05-19-tool-terminal/specs/spec.md diff --git a/openspec/changes/tool-terminal/tasks.md b/openspec/changes/archive/2026-05-19-tool-terminal/tasks.md similarity index 100% rename from openspec/changes/tool-terminal/tasks.md rename to openspec/changes/archive/2026-05-19-tool-terminal/tasks.md