chore: initial project scaffold and gitignore
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Core application modules."""
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"},
|
||||
)
|
||||
Reference in New Issue
Block a user