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:
@@ -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
|
||||||
@@ -25,8 +25,21 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
yield session
|
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:
|
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()
|
settings = Settings()
|
||||||
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
redirect_uri = f"{settings.api_base_url}/auth/callback"
|
||||||
state = token_urlsafe(24)
|
state = token_urlsafe(24)
|
||||||
|
|||||||
@@ -12,11 +12,24 @@ from src.models.ssh_key import SSHKey
|
|||||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
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(
|
async def get_dashboard_summary(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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
|
# Count user's projects
|
||||||
projects_result = await session.execute(
|
projects_result = await session.execute(
|
||||||
select(func.count()).select_from(Project).where(Project.owner_id == user_id)
|
select(func.count()).select_from(Project).where(Project.owner_id == user_id)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
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)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
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,
|
user_id: uuid.UUID,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> Project:
|
) -> 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)
|
project = await session.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
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:
|
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"
|
base = Settings().repo_base_path or "/data/repos"
|
||||||
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
return os.path.join(base, str(user_id), str(project_id), f"{name}.git")
|
||||||
|
|
||||||
@@ -97,12 +121,27 @@ class GitRepositoryResponse(BaseModel):
|
|||||||
updated_at: datetime
|
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(
|
async def list_repositories(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[GitRepository]:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -112,13 +151,29 @@ async def list_repositories(
|
|||||||
return list(result.scalars().all())
|
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(
|
async def delete_repository(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Response:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_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)
|
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:
|
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)
|
result = parse_git_url(data.url)
|
||||||
return URLParseResponse(**result)
|
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(
|
async def create_repository(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: GitRepositoryCreate,
|
data: GitRepositoryCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> GitRepository:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -229,7 +313,11 @@ async def create_repository(
|
|||||||
return repo
|
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(
|
async def get_repository_history(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_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),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_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))
|
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(
|
async def get_repository_commit(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_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),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -322,7 +439,12 @@ class FileUpdateResponse(BaseModel):
|
|||||||
branch: str
|
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(
|
async def list_repository_files(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_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),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> FileListResponse:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_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))
|
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(
|
async def get_repository_file_content(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_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),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> FileContentResponse:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_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))
|
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(
|
async def get_repository_branches(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> BranchesResponse:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_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))
|
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(
|
async def update_repository_file(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_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),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> FileUpdateResponse:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -493,14 +675,29 @@ class StatusResponse(BaseModel):
|
|||||||
behind: int
|
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(
|
async def get_repository_status(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> StatusResponse:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -536,7 +733,11 @@ class CheckoutRequest(BaseModel):
|
|||||||
branch: str
|
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(
|
async def create_repository_branch(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_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),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_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))
|
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(
|
async def delete_repository_branch(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_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),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_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))
|
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(
|
async def checkout_repository_branch(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_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),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -625,7 +868,12 @@ class CommitResponse(BaseModel):
|
|||||||
message: str
|
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(
|
async def commit_repository_changes(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_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),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> CommitResponse:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -669,14 +928,29 @@ class FetchResponse(BaseModel):
|
|||||||
message: str
|
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(
|
async def fetch_repository(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> FetchResponse:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -698,7 +972,12 @@ class PullResponse(BaseModel):
|
|||||||
message: str
|
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(
|
async def pull_repository(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -706,7 +985,18 @@ async def pull_repository(
|
|||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> PullResponse:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -728,7 +1018,12 @@ class PushResponse(BaseModel):
|
|||||||
message: str
|
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(
|
async def push_repository(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -736,7 +1031,18 @@ async def push_repository(
|
|||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> PushResponse:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -765,7 +1071,12 @@ class MergeResponse(BaseModel):
|
|||||||
message: str
|
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(
|
async def merge_repository_branches(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_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),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> MergeResponse:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -17,6 +17,7 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
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)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
@@ -47,12 +48,28 @@ class SetDefaultSSHKeyRequest(BaseModel):
|
|||||||
ssh_key_id: uuid.UUID
|
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(
|
async def create_project(
|
||||||
data: ProjectCreate,
|
data: ProjectCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> 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)
|
user = await _get_user(session, user_id)
|
||||||
project = Project(
|
project = Project(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
@@ -66,22 +83,51 @@ async def create_project(
|
|||||||
return 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(
|
async def list_projects(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[Project]:
|
) -> 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)
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
||||||
return list(result.scalars().all())
|
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(
|
async def get_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> 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)
|
await _get_user(session, user_id)
|
||||||
return await _get_owned_project(project_id, user_id, session)
|
return await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -91,6 +137,19 @@ async def _get_owned_project(
|
|||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> Project:
|
) -> 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)
|
project = await session.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="project not found")
|
||||||
@@ -99,13 +158,29 @@ async def _get_owned_project(
|
|||||||
return 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(
|
async def update_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: ProjectUpdate,
|
data: ProjectUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> 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)
|
await _get_user(session, user_id)
|
||||||
project = await _get_owned_project(project_id, user_id, session)
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -119,12 +194,27 @@ async def update_project(
|
|||||||
return 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(
|
async def delete_project(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Response:
|
) -> 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)
|
await _get_user(session, user_id)
|
||||||
project = await _get_owned_project(project_id, user_id, session)
|
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)
|
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(
|
async def set_default_ssh_key(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
data: SetDefaultSSHKeyRequest,
|
data: SetDefaultSSHKeyRequest,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> Project:
|
) -> 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)
|
user = await _get_user(session, user_id)
|
||||||
project = await _get_owned_project(project_id, user_id, session)
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
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)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
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]:
|
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()
|
private_key = Ed25519PrivateKey.generate()
|
||||||
public_key = private_key.public_key()
|
public_key = private_key.public_key()
|
||||||
|
|
||||||
@@ -68,12 +74,28 @@ class SSHKeyResponse(BaseModel):
|
|||||||
created_at: datetime
|
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(
|
async def create_ssh_key(
|
||||||
data: SSHKeyCreate,
|
data: SSHKeyCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> SSHKey:
|
) -> 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)
|
user = await _get_user(session, user_id)
|
||||||
private_key, public_key = generate_ssh_key_pair()
|
private_key, public_key = generate_ssh_key_pair()
|
||||||
|
|
||||||
@@ -92,22 +114,51 @@ async def create_ssh_key(
|
|||||||
return 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(
|
async def list_ssh_keys(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[SSHKey]:
|
) -> 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)
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
result = await session.execute(select(SSHKey).where(SSHKey.user_id == user.id))
|
||||||
return list(result.scalars().all())
|
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(
|
async def delete_ssh_key(
|
||||||
key_id: uuid.UUID,
|
key_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> 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)
|
user = await _get_user(session, user_id)
|
||||||
ssh_key = await session.get(SSHKey, key_id)
|
ssh_key = await session.get(SSHKey, key_id)
|
||||||
if ssh_key is None or ssh_key.user_id != user.id:
|
if ssh_key is None or ssh_key.user_id != user.id:
|
||||||
|
|||||||
@@ -13,13 +13,26 @@ from src.services.terminal_manager import terminal_manager
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/tool-instances/{instance_id}/terminal")
|
@router.websocket(
|
||||||
|
"/ws/tool-instances/{instance_id}/terminal",
|
||||||
|
)
|
||||||
async def terminal_websocket(
|
async def terminal_websocket(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
instance_id: str,
|
instance_id: str,
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> 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()
|
await websocket.accept()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -82,7 +95,15 @@ async def _get_user_from_websocket(
|
|||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
) -> uuid.UUID | None:
|
) -> 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
|
from src.auth.session import verify_session_token
|
||||||
|
|
||||||
session_cookie = websocket.cookies.get("session")
|
session_cookie = websocket.cookies.get("session")
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ router = APIRouter(prefix="/projects", tags=["tool-instances"])
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
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)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -41,6 +42,19 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
|||||||
async def _get_owned_project(
|
async def _get_owned_project(
|
||||||
project_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession
|
project_id: uuid.UUID, user_id: uuid.UUID, session: AsyncSession
|
||||||
) -> Project:
|
) -> 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)
|
project = await session.get(Project, project_id)
|
||||||
if project is None or project.owner_id != user_id:
|
if project is None or project.owner_id != user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -49,7 +63,11 @@ async def _get_owned_project(
|
|||||||
return 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(
|
async def create_instance(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -58,7 +76,19 @@ async def create_instance(
|
|||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_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(
|
async def list_instances(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_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(
|
async def get_instance(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -173,7 +221,18 @@ async def get_instance(
|
|||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_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(
|
async def start_instance(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -218,7 +281,18 @@ async def start_instance(
|
|||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_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}
|
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(
|
async def stop_instance(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -270,7 +348,18 @@ async def stop_instance(
|
|||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -291,7 +380,11 @@ async def stop_instance(
|
|||||||
return {"status": instance.status}
|
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(
|
async def restart_instance(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -299,7 +392,18 @@ async def restart_instance(
|
|||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -326,7 +430,11 @@ async def restart_instance(
|
|||||||
return {"status": instance.status}
|
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(
|
async def delete_instance(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_id: uuid.UUID,
|
repo_id: uuid.UUID,
|
||||||
@@ -334,7 +442,18 @@ async def delete_instance(
|
|||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
@@ -359,7 +478,11 @@ async def delete_instance(
|
|||||||
await session.commit()
|
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(
|
async def get_instance_logs(
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
repo_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),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
_project = await _get_owned_project(project_id, user_id, session)
|
_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 = 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(
|
async def get_user_sessions(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
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)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
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:
|
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
|
# For now, all authenticated users can manage tool types
|
||||||
# In production, check user.role or similar
|
# In production, check user.role or similar
|
||||||
pass
|
pass
|
||||||
@@ -117,12 +123,28 @@ class ToolTypeResponse(BaseModel):
|
|||||||
updated_at: datetime
|
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(
|
async def create_tool_type(
|
||||||
data: ToolTypeCreate,
|
data: ToolTypeCreate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> 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)
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
@@ -146,22 +168,51 @@ async def create_tool_type(
|
|||||||
return 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(
|
async def list_tool_types(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[ToolType]:
|
) -> 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)
|
await _get_user(session, user_id)
|
||||||
result = await session.execute(select(ToolType).order_by(ToolType.name))
|
result = await session.execute(select(ToolType).order_by(ToolType.name))
|
||||||
return list(result.scalars().all())
|
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(
|
async def get_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> 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)
|
await _get_user(session, user_id)
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
@@ -169,13 +220,29 @@ async def get_tool_type(
|
|||||||
return 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(
|
async def update_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
data: ToolTypeUpdate,
|
data: ToolTypeUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> ToolType:
|
) -> 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)
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
@@ -217,12 +284,27 @@ async def update_tool_type(
|
|||||||
return 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(
|
async def delete_tool_type(
|
||||||
tool_type_id: uuid.UUID,
|
tool_type_id: uuid.UUID,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> None:
|
) -> 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)
|
user = await _get_user(session, user_id)
|
||||||
await _require_admin(user)
|
await _require_admin(user)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ router = APIRouter(prefix="/users/me", tags=["user-config"])
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
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)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
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:
|
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))
|
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
||||||
config = result.scalar_one_or_none()
|
config = result.scalar_one_or_none()
|
||||||
if config is None:
|
if config is None:
|
||||||
@@ -46,22 +56,51 @@ class UserConfigUpdate(BaseModel):
|
|||||||
git_user_email: str | None = None
|
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(
|
async def get_user_config(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
config = await _get_or_create_config(session, user_id)
|
config = await _get_or_create_config(session, user_id)
|
||||||
return UserConfigResponse.model_validate(config.config)
|
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(
|
async def update_user_config(
|
||||||
data: UserConfigUpdate,
|
data: UserConfigUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserConfigResponse:
|
) -> 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)
|
_user = await _get_user(session, user_id)
|
||||||
config = await _get_or_create_config(session, user_id)
|
config = await _get_or_create_config(session, user_id)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
|||||||
|
|
||||||
|
|
||||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
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)
|
user = await session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||||
@@ -37,20 +38,49 @@ class UserProfileUpdate(BaseModel):
|
|||||||
email: str | None = None
|
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(
|
async def get_profile(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> 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)
|
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(
|
async def update_profile(
|
||||||
data: UserProfileUpdate,
|
data: UserProfileUpdate,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> 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)
|
user = await _get_user(session, user_id)
|
||||||
|
|
||||||
if data.name is not None:
|
if data.name is not None:
|
||||||
@@ -68,12 +98,27 @@ async def update_profile(
|
|||||||
return user
|
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(
|
async def upload_avatar(
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> User:
|
) -> 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)
|
user = await _get_user(session, user_id)
|
||||||
|
|
||||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||||
|
|||||||
+2
-11
@@ -9,6 +9,7 @@ from sqlalchemy import select, text
|
|||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
from src.api.dashboard import router as dashboard_router
|
from src.api.dashboard import router as dashboard_router
|
||||||
from src.api.git_repositories import router as git_repositories_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.projects import router as projects_router
|
||||||
from src.api.ssh_keys import router as ssh_keys_router
|
from src.api.ssh_keys import router as ssh_keys_router
|
||||||
from src.api.terminal import router as terminal_router
|
from src.api.terminal import router as terminal_router
|
||||||
@@ -148,17 +149,7 @@ async def on_startup():
|
|||||||
await seed_builtin_tool_types()
|
await seed_builtin_tool_types()
|
||||||
logger.info("Startup complete.")
|
logger.info("Startup complete.")
|
||||||
|
|
||||||
@app.get("/health")
|
app.include_router(health_router)
|
||||||
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(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
app.include_router(projects_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
|
||||||
Reference in New Issue
Block a user