chore: initial project scaffold and gitignore

This commit is contained in:
2026-04-03 07:33:32 +00:00
commit 3e3acbd13d
41 changed files with 3658 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Core application modules."""
+79
View File
@@ -0,0 +1,79 @@
"""Bearer token authentication and authorization."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import Settings, get_settings
class Permission(StrEnum):
READ = "read"
TRADE_WRITE = "trade-write"
ADMIN = "admin"
@dataclass(frozen=True)
class AuthContext:
token: str
permission: Permission
security = HTTPBearer(auto_error=False)
def _resolve_permission(token: str, settings: Settings) -> Permission | None:
if token in settings.admin_tokens:
return Permission.ADMIN
if token in settings.trade_write_tokens:
return Permission.TRADE_WRITE
if token in settings.read_tokens:
return Permission.READ
return None
def _has_permission(current: Permission, required: Permission) -> bool:
if current == Permission.ADMIN:
return True
if current == required:
return True
if current == Permission.TRADE_WRITE and required == Permission.READ:
return True
return False
def require_permission(
required: Permission,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
settings: Settings = Depends(get_settings),
) -> AuthContext:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
permission = _resolve_permission(credentials.credentials, settings)
if permission is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
if not _has_permission(permission, required):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permission")
return AuthContext(token=credentials.credentials, permission=permission)
def permission_dependency(required: Permission):
def dependency(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
settings: Settings = Depends(get_settings),
) -> AuthContext:
return require_permission(required, credentials=credentials, settings=settings)
return dependency
require_read = permission_dependency(Permission.READ)
require_trade_write = permission_dependency(Permission.TRADE_WRITE)
require_admin = permission_dependency(Permission.ADMIN)
+130
View File
@@ -0,0 +1,130 @@
"""Application configuration."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from urllib.parse import quote_plus
from dotenv import load_dotenv
from pydantic import Field, computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict
# Load .env file from project root
env_file = Path(__file__).parent.parent.parent / ".env"
if env_file.exists():
load_dotenv(env_file)
class Settings(BaseSettings):
app_name: str = "PortfolioHub"
app_version: str = "0.1.0"
environment: str = "development"
allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS")
read_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_READ_TOKENS")
trade_write_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_TRADE_WRITE_TOKENS")
admin_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_ADMIN_TOKENS")
db_enabled: bool = Field(default=False, validation_alias="PORTFOLIOHUB_DB_ENABLED")
# External services configuration
timescaledb_host: str = Field(default="localhost", validation_alias="TIMESCALEDB_HOST")
timescaledb_port: int = Field(default=5432, validation_alias="TIMESCALEDB_PORT")
timescaledb_name: str = Field(default="portfoliohub", validation_alias="TIMESCALEDB_NAME")
timescaledb_user: str = Field(default="portfoliohub", validation_alias="TIMESCALEDB_USER")
timescaledb_password: str = Field(default="", validation_alias="TIMESCALEDB_PASSWORD")
timescaledb_sslmode: str = Field(default="prefer", validation_alias="TIMESCALEDB_SSLMODE")
timescaledb_connect_timeout_sec: int = Field(default=10, validation_alias="TIMESCALEDB_CONNECT_TIMEOUT_SEC")
timescaledb_application_name: str = Field(default="portfoliohub", validation_alias="TIMESCALEDB_APPLICATION_NAME")
redis_host: str = Field(default="localhost", validation_alias="REDIS_HOST")
redis_port: int = Field(default=6379, validation_alias="REDIS_PORT")
redis_db: int = Field(default=0, validation_alias="REDIS_DB")
redis_password: str = Field(default="", validation_alias="REDIS_PASSWORD")
loki_host: str = Field(default="localhost", validation_alias="LOKI_HOST")
loki_port: int = Field(default=3100, validation_alias="LOKI_PORT")
loki_enabled: bool = Field(default=False, validation_alias="LOKI_ENABLED")
model_config = SettingsConfigDict(
env_prefix="PORTFOLIOHUB_",
case_sensitive=False,
extra="ignore",
)
@computed_field
@property
def allowed_origins(self) -> list[str]:
return [item.strip() for item in self.allowed_origins_raw.split(",") if item.strip()]
@computed_field
@property
def read_tokens(self) -> set[str]:
return {item.strip() for item in self.read_tokens_raw.split(",") if item.strip()}
@computed_field
@property
def trade_write_tokens(self) -> set[str]:
return {item.strip() for item in self.trade_write_tokens_raw.split(",") if item.strip()}
@computed_field
@property
def admin_tokens(self) -> set[str]:
return {item.strip() for item in self.admin_tokens_raw.split(",") if item.strip()}
@computed_field
@property
def active_db_host(self) -> str:
return self.timescaledb_host
@computed_field
@property
def active_db_port(self) -> int:
return self.timescaledb_port
@computed_field
@property
def active_db_name(self) -> str:
return self.timescaledb_name
@computed_field
@property
def active_db_user(self) -> str:
return self.timescaledb_user
@computed_field
@property
def active_db_password(self) -> str:
return self.timescaledb_password
@computed_field
@property
def active_db_sslmode(self) -> str:
return self.timescaledb_sslmode
@computed_field
@property
def active_db_connect_timeout_sec(self) -> int:
return self.timescaledb_connect_timeout_sec
@computed_field
@property
def active_db_application_name(self) -> str:
return self.timescaledb_application_name
@computed_field
@property
def active_database_url(self) -> str:
user = quote_plus(self.active_db_user)
password = quote_plus(self.active_db_password)
database = quote_plus(self.active_db_name)
return (
f"postgresql+psycopg://{user}:{password}@{self.active_db_host}:{self.active_db_port}/{database}"
f"?sslmode={quote_plus(self.active_db_sslmode)}"
f"&connect_timeout={self.active_db_connect_timeout_sec}"
f"&application_name={quote_plus(self.active_db_application_name)}"
)
@lru_cache
def get_settings() -> Settings:
return Settings()
+21
View File
@@ -0,0 +1,21 @@
"""Exception types and FastAPI handlers."""
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
class PortfolioHubError(Exception):
"""Base application exception."""
class FeatureNotReadyError(PortfolioHubError):
"""Raised when an endpoint contract exists but the implementation is pending."""
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(FeatureNotReadyError)
async def handle_not_ready(_: Request, exc: FeatureNotReadyError) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
content={"detail": str(exc) or "Feature not implemented yet"},
)