feat: initial FastAPI boilerplate setup

Set up Webber - multi-agent AI development system with:

- Domain-based project structure (src/domains/, src/shared/)
- BaseController pattern with lazy router instantiation
- Pydantic Settings configuration with env file support
- Logger decorator with temporal benchmarking and trace IDs
- UserProvider singleton for request-scoped context
- Custom exception hierarchy
- Health endpoints (/, /health)

Dependencies (CVE checked 2026-01-09):
- FastAPI 0.128.0, Starlette 0.50.0, Uvicorn 0.40.0
- Pydantic 2.12.4, PydanticAI 1.40.0
- All packages at latest safe versions

Placeholder domains for future implementation:
- agents/ (explore, plan, task)
- tools/ (file, shell, search)
- auth/ (tatlock integration)

Port: 8086 (per CONTAINERS.md allocation)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-09 18:38:08 +01:00
co-authored by Claude Opus 4.5
parent 5f6b36f9c5
commit a61fbe5a88
38 changed files with 2970 additions and 0 deletions
View File
+106
View File
@@ -0,0 +1,106 @@
"""
Authentication utilities.
Provides API key validation and integration with external auth services.
"""
from typing import Optional
from fastapi import Request, HTTPException, Depends
from fastapi.security import APIKeyHeader
from src.shared.config import get_settings
from src.shared.context import User, user_provider
from src.shared.logging import get_logger
logger = get_logger(__name__)
settings = get_settings()
# API key header scheme
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def validate_api_key(api_key: str) -> Optional[User]:
"""
Validate API key and return User if valid.
TODO: Integrate with tatlock-ui/core-api for real validation.
Currently accepts any non-empty key for development.
"""
if not api_key:
return None
# Development mode: accept any key
if settings.debug:
logger.debug(f"Debug mode: accepting API key {api_key[:8]}...")
return User(
id="dev-user",
email="dev@localhost",
api_key=api_key,
tenant_id="dev-tenant",
roles=["admin"],
)
# TODO: Production mode - validate against tatlock API
# async with httpx.AsyncClient() as client:
# response = await client.get(
# f"{settings.tatlock_api_url}/auth/validate",
# headers={"X-API-Key": api_key}
# )
# if response.status_code == 200:
# data = response.json()
# return User(**data)
return None
async def get_api_key(
api_key: Optional[str] = Depends(api_key_header),
) -> Optional[str]:
"""FastAPI dependency to extract API key from header."""
return api_key
async def get_current_user_dep(
api_key: Optional[str] = Depends(get_api_key),
) -> Optional[User]:
"""
FastAPI dependency to get current user from API key.
Returns None if no valid API key provided.
"""
if not api_key:
return None
return await validate_api_key(api_key)
async def require_auth(
user: Optional[User] = Depends(get_current_user_dep),
) -> User:
"""
FastAPI dependency that requires authentication.
Raises 401 if no valid API key provided.
"""
if user is None:
raise HTTPException(
status_code=401,
detail="Invalid or missing API key",
headers={"WWW-Authenticate": "ApiKey"},
)
return user
async def require_admin(
user: User = Depends(require_auth),
) -> User:
"""
FastAPI dependency that requires admin role.
Raises 403 if user is not admin.
"""
if "admin" not in user.roles:
raise HTTPException(
status_code=403,
detail="Admin access required",
)
return user
+74
View File
@@ -0,0 +1,74 @@
"""
Base classes for controllers and schemas.
All domain controllers and Pydantic models should inherit from these.
"""
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any
from fastapi import APIRouter
from pydantic import BaseModel, ConfigDict
class BaseController(ABC):
"""
Base controller with lazy router instantiation.
All domain controllers inherit from this and implement create_router().
Usage:
class UsersController(BaseController):
def __init__(self):
super().__init__(prefix="/users", tags=["Users"])
def create_router(self) -> APIRouter:
router = APIRouter(prefix=self.prefix, tags=self.tags)
@router.get("/")
async def list_users():
return []
return router
users_controller = UsersController()
"""
def __init__(self, prefix: str, tags: list[str]):
self.prefix = prefix
self.tags = tags
self._router = None
@abstractmethod
def create_router(self) -> APIRouter:
"""Create and configure the FastAPI router with all routes."""
pass
@property
def router(self) -> APIRouter:
"""Lazy router instantiation."""
if self._router is None:
self._router = self.create_router()
return self._router
class BaseSchema(BaseModel):
"""
Base Pydantic model with standardized configuration.
All domain schemas should inherit from this.
"""
model_config = ConfigDict(
strict=False,
populate_by_name=True,
use_enum_values=True,
validate_assignment=True,
json_encoders={
datetime: lambda v: v.isoformat() if v else None
}
)
def dict_without_none(self) -> dict[str, Any]:
"""Return model as dict, excluding None values."""
return {k: v for k, v in self.model_dump().items() if v is not None}
+76
View File
@@ -0,0 +1,76 @@
"""
Application configuration via Pydantic Settings.
All settings loaded from environment variables or .env file.
"""
import tomllib
from pathlib import Path
from functools import lru_cache
from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
def _get_version() -> str:
"""Load version from pyproject.toml."""
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
try:
with open(pyproject_path, "rb") as f:
return tomllib.load(f).get("project", {}).get("version", "0.0.0")
except FileNotFoundError:
return "0.0.0"
__version__ = _get_version()
class Settings(BaseSettings):
"""Application settings loaded from environment."""
# Application
app_name: str = "Webber"
app_version: str = __version__
debug: bool = False
# Server
host: str = "0.0.0.0"
port: int = 8086
# Logging
log_level: str = "INFO"
# CORS
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:8080"]
cors_credentials: bool = True
cors_methods: list[str] = ["*"]
cors_headers: list[str] = ["*"]
# LLM - Ollama (always hot in VRAM on tower-of-joy)
ollama_url: str = "http://192.168.86.149:11434"
ollama_agent_model: str = "mistral-nemo-large:latest"
ollama_embed_model: str = "nomic-embed-text:latest"
# Auth - Tatlock integration
tatlock_api_url: Optional[str] = "http://192.168.86.149:8000"
internal_api_key: Optional[str] = None
# Tool execution
tool_timeout_seconds: int = 120
sandbox_enabled: bool = True
allowed_paths: list[str] = []
# Sessions
session_ttl_hours: int = 24
max_context_tokens: int = 128000
model_config = SettingsConfigDict(
env_file=".env",
case_sensitive=False,
extra="ignore",
)
@lru_cache()
def get_settings() -> Settings:
"""Cached settings singleton."""
return Settings()
+86
View File
@@ -0,0 +1,86 @@
"""
Request context management.
Provides UserProvider singleton for request-scoped user context.
Set once per request in middleware, accessible everywhere without
passing user through function parameters.
"""
from dataclasses import dataclass, field
from typing import Optional
from contextvars import ContextVar
@dataclass
class User:
"""Authenticated user context."""
id: str
email: str
api_key: str
tenant_id: Optional[str] = None
roles: list[str] = field(default_factory=list)
_current_user: ContextVar[Optional[User]] = ContextVar('current_user', default=None)
class UserProvider:
"""
Singleton for request-scoped user context.
Set once per request in middleware, accessible everywhere without
passing user through function parameters.
Usage in middleware:
user = await validate_api_key(request)
user_provider.set_user(user)
try:
response = await call_next(request)
finally:
user_provider.clear_user()
Usage anywhere:
from src.shared.context import get_current_user, require_user
user = get_current_user() # Returns None if not authenticated
user = require_user() # Raises if not authenticated
"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def set_user(self, user: User) -> None:
"""Set current user for this request context."""
_current_user.set(user)
def get_user(self) -> Optional[User]:
"""Get current user (may be None)."""
return _current_user.get()
def clear_user(self) -> None:
"""Clear current user (call in finally block)."""
_current_user.set(None)
@property
def current_user(self) -> Optional[User]:
"""Property access to current user."""
return self.get_user()
# Global singleton
user_provider = UserProvider()
def get_current_user() -> Optional[User]:
"""Get current user or None."""
return user_provider.get_user()
def require_user() -> User:
"""Get current user or raise ValueError."""
user = user_provider.get_user()
if user is None:
raise ValueError("No authenticated user in context")
return user
+107
View File
@@ -0,0 +1,107 @@
"""
Custom exception hierarchy.
All application exceptions inherit from AppException.
"""
from typing import Any, Optional
class AppException(Exception):
"""Base exception for application errors."""
def __init__(
self,
message: str,
status_code: int = 500,
error_code: Optional[str] = None,
details: Optional[dict[str, Any]] = None,
):
self.message = message
self.status_code = status_code
self.error_code = error_code or self.__class__.__name__
self.details = details or {}
super().__init__(message)
def to_dict(self) -> dict[str, Any]:
"""Convert to JSON-serializable dict."""
return {
"error": self.error_code,
"message": self.message,
"details": self.details,
}
class NotFoundError(AppException):
"""Resource not found."""
def __init__(self, resource: str, identifier: Any):
super().__init__(
message=f"{resource} not found: {identifier}",
status_code=404,
details={"resource": resource, "identifier": str(identifier)},
)
class ValidationError(AppException):
"""Input validation failed."""
def __init__(self, message: str, field: Optional[str] = None):
super().__init__(
message=message,
status_code=422,
details={"field": field} if field else {},
)
class AuthenticationError(AppException):
"""Authentication required or failed."""
def __init__(self, message: str = "Authentication required"):
super().__init__(message=message, status_code=401)
class AuthorizationError(AppException):
"""Permission denied."""
def __init__(self, message: str = "Permission denied"):
super().__init__(message=message, status_code=403)
class ConflictError(AppException):
"""Resource conflict (e.g., duplicate)."""
def __init__(self, message: str):
super().__init__(message=message, status_code=409)
class RateLimitError(AppException):
"""Rate limit exceeded."""
def __init__(self, retry_after: int = 60):
super().__init__(
message="Rate limit exceeded",
status_code=429,
details={"retry_after": retry_after},
)
class ServiceUnavailableError(AppException):
"""External service unavailable."""
def __init__(self, service: str, message: Optional[str] = None):
super().__init__(
message=message or f"Service unavailable: {service}",
status_code=503,
details={"service": service},
)
class TimeoutError(AppException):
"""Operation timed out."""
def __init__(self, operation: str, timeout_seconds: float):
super().__init__(
message=f"Operation timed out: {operation}",
status_code=504,
details={"operation": operation, "timeout_seconds": timeout_seconds},
)
+239
View File
@@ -0,0 +1,239 @@
"""
Centralized logging with temporal benchmarking.
Provides:
- @logged() decorator for automatic function timing
- trace_span() context manager for manual instrumentation
- Trace ID correlation across nested calls
- Configurable slow/warn thresholds
"""
import functools
import asyncio
import time
import logging
import sys
from pathlib import Path
from typing import Callable, Optional
from contextvars import ContextVar
from dataclasses import dataclass, field
from uuid import uuid4
# === Trace Context ===
@dataclass
class TraceSpan:
"""Represents a timed execution span."""
name: str
trace_id: str
parent_id: Optional[str] = None
span_id: str = field(default_factory=lambda: uuid4().hex[:8])
start_time: float = field(default_factory=time.perf_counter)
end_time: Optional[float] = None
@property
def duration_ms(self) -> float:
"""Get duration in milliseconds."""
end = self.end_time or time.perf_counter()
return (end - self.start_time) * 1000
_current_span: ContextVar[Optional[TraceSpan]] = ContextVar('current_span', default=None)
_trace_id: ContextVar[Optional[str]] = ContextVar('trace_id', default=None)
def get_current_trace_id() -> Optional[str]:
"""Get current trace ID for log correlation."""
return _trace_id.get()
def get_current_span() -> Optional[TraceSpan]:
"""Get current trace span."""
return _current_span.get()
# === Setup ===
def setup_logging(log_level: str = "INFO") -> None:
"""Configure application logging."""
log_dir = Path("logs")
log_dir.mkdir(exist_ok=True)
logging.basicConfig(
level=getattr(logging, log_level.upper()),
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(log_dir / "webber.log", encoding="utf-8")
]
)
# Quiet noisy libraries
for name in ["httpx", "httpcore", "uvicorn.access", "uvicorn.error"]:
logging.getLogger(name).setLevel(logging.WARNING)
def get_logger(name: str) -> logging.Logger:
"""Get a logger instance."""
return logging.getLogger(name)
# === Decorator ===
def logged(
logger: logging.Logger = None,
slow_threshold_ms: float = 100.0,
warn_threshold_ms: float = 500.0,
include_args: bool = False,
):
"""
Decorator for automatic function logging with temporal benchmarking.
Args:
logger: Logger instance (defaults to module logger)
slow_threshold_ms: Log INFO if execution exceeds this (default 100ms)
warn_threshold_ms: Log WARNING if execution exceeds this (default 500ms)
include_args: Include function arguments in log (careful with sensitive data)
Usage:
@logged()
async def my_function(): ...
@logged(slow_threshold_ms=50, warn_threshold_ms=200)
def critical_path(): ...
"""
def decorator(func: Callable):
nonlocal logger
if logger is None:
logger = logging.getLogger(func.__module__)
func_name = f"{func.__module__}.{func.__qualname__}"
def _create_span() -> TraceSpan:
parent = _current_span.get()
trace_id = _trace_id.get() or uuid4().hex[:16]
if _trace_id.get() is None:
_trace_id.set(trace_id)
return TraceSpan(
name=func_name,
trace_id=trace_id,
parent_id=parent.span_id if parent else None,
)
def _log_completion(span: TraceSpan, error: Exception = None):
span.end_time = time.perf_counter()
duration = span.duration_ms
tid = span.trace_id[:8]
if error:
logger.error(
f"[{tid}] {func_name} FAILED after {duration:.2f}ms: {error}",
exc_info=True
)
elif duration >= warn_threshold_ms:
logger.warning(
f"[{tid}] {func_name} SLOW: {duration:.2f}ms (threshold: {warn_threshold_ms}ms)"
)
elif duration >= slow_threshold_ms:
logger.info(f"[{tid}] {func_name} completed in {duration:.2f}ms")
else:
logger.debug(f"[{tid}] {func_name} completed in {duration:.2f}ms")
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
span = _create_span()
token = _current_span.set(span)
if include_args:
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}({args}, {kwargs})")
else:
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
try:
result = await func(*args, **kwargs)
_log_completion(span)
return result
except Exception as e:
_log_completion(span, error=e)
raise
finally:
_current_span.reset(token)
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
span = _create_span()
token = _current_span.set(span)
if include_args:
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}({args}, {kwargs})")
else:
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
try:
result = func(*args, **kwargs)
_log_completion(span)
return result
except Exception as e:
_log_completion(span, error=e)
raise
finally:
_current_span.reset(token)
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
return decorator
# === Context Manager ===
class trace_span:
"""
Context manager for manual span creation.
Usage:
with trace_span("database_query"):
result = db.execute(query)
async with trace_span("llm_call"):
response = await agent.run(prompt)
"""
def __init__(self, name: str, logger: logging.Logger = None):
self.name = name
self.logger = logger or logging.getLogger(__name__)
self.span: Optional[TraceSpan] = None
self.token = None
def __enter__(self) -> TraceSpan:
parent = _current_span.get()
trace_id = _trace_id.get() or uuid4().hex[:16]
if _trace_id.get() is None:
_trace_id.set(trace_id)
self.span = TraceSpan(
name=self.name,
trace_id=trace_id,
parent_id=parent.span_id if parent else None,
)
self.token = _current_span.set(self.span)
self.logger.debug(f"[{self.span.trace_id[:8]}] -> {self.name}")
return self.span
def __exit__(self, exc_type, exc_val, exc_tb):
if self.span:
self.span.end_time = time.perf_counter()
duration = self.span.duration_ms
tid = self.span.trace_id[:8]
if exc_val:
self.logger.error(f"[{tid}] {self.name} FAILED: {duration:.2f}ms")
else:
self.logger.debug(f"[{tid}] {self.name}: {duration:.2f}ms")
if self.token:
_current_span.reset(self.token)
return False
async def __aenter__(self) -> TraceSpan:
return self.__enter__()
async def __aexit__(self, exc_type, exc_val, exc_tb):
return self.__exit__(exc_type, exc_val, exc_tb)