docs: comprehensive API documentation

- Create enhanced health endpoints with /health and /health/db
- Add comprehensive docstrings to all API endpoints
- Add Pydantic response models with Field descriptions
- Create apps/api/README.md with setup guide
- Create ADR-001 for session auth decision
- Create ADR-002 for async SQLAlchemy decision
- Quality gates: Python syntax OK, TypeScript OK
This commit is contained in:
Fusion
2026-05-19 21:31:20 +02:00
parent e344e961d6
commit 40a940304b
25 changed files with 1848 additions and 88 deletions
+252
View File
@@ -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
+14 -1
View File
@@ -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)
+14 -1
View File
@@ -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)
+357 -35
View File
@@ -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)
+149
View File
@@ -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()
+112 -6
View File
@@ -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)
+54 -3
View File
@@ -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:
+24 -3
View File
@@ -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")
+165 -18
View File
@@ -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(
+87 -5
View File
@@ -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)
+41 -2
View File
@@ -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)
+48 -3
View File
@@ -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:
+2 -11
View File
@@ -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)
@@ -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
@@ -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
@@ -0,0 +1,2 @@
schema: spec-driven
name: api-documentation
@@ -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
@@ -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
@@ -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
@@ -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