feat: project skeleton
- infra (k8s, kind, helm, docker) backbone is implemented - security: implementation + unit tests are done
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
0
app/api/v1/__init__.py
Normal file
46
app/api/v1/health.py
Normal file
46
app/api/v1/health.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Health check endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Response, status
|
||||
|
||||
from app.db import db, redis_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
"""Liveness probe - returns 200 if the service is running."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/readyz")
|
||||
async def readyz(response: Response) -> dict[str, str | dict[str, bool]]:
|
||||
"""
|
||||
Readiness probe - checks database and Redis connectivity.
|
||||
- Check Postgres status
|
||||
- Check Redis status
|
||||
- Return overall healthiness
|
||||
"""
|
||||
checks = {
|
||||
"postgres": False,
|
||||
"redis": False,
|
||||
}
|
||||
|
||||
try:
|
||||
if db.pool:
|
||||
async with db.connection() as conn:
|
||||
await conn.fetchval("SELECT 1")
|
||||
checks["postgres"] = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
checks["redis"] = await redis_client.ping()
|
||||
|
||||
all_healthy = all(checks.values())
|
||||
if not all_healthy:
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
|
||||
return {
|
||||
"status": "ok" if all_healthy else "degraded",
|
||||
"checks": checks,
|
||||
}
|
||||
34
app/config.py
Normal file
34
app/config.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Application configuration via pydantic-settings."""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# Database
|
||||
database_url: str
|
||||
|
||||
# Redis
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# JWT
|
||||
jwt_secret_key: str
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_issuer: str = "incidentops"
|
||||
jwt_audience: str = "incidentops-api"
|
||||
access_token_expire_minutes: int = 15
|
||||
refresh_token_expire_days: int = 30
|
||||
|
||||
# Application
|
||||
debug: bool = False
|
||||
api_v1_prefix: str = "/v1"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
59
app/core/exceptions.py
Normal file
59
app/core/exceptions.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Custom HTTP exceptions for the API."""
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
class NotFoundError(HTTPException):
|
||||
"""Resource not found."""
|
||||
|
||||
def __init__(self, detail: str = "Resource not found") -> None:
|
||||
super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
|
||||
|
||||
|
||||
class ConflictError(HTTPException):
|
||||
"""Conflict with current state (e.g., version mismatch)."""
|
||||
|
||||
def __init__(self, detail: str = "Conflict with current state") -> None:
|
||||
super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail)
|
||||
|
||||
|
||||
class UnauthorizedError(HTTPException):
|
||||
"""Authentication required or failed."""
|
||||
|
||||
def __init__(self, detail: str = "Not authenticated") -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=detail,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
class ForbiddenError(HTTPException):
|
||||
"""Insufficient permissions."""
|
||||
|
||||
def __init__(self, detail: str = "Insufficient permissions") -> None:
|
||||
super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
|
||||
|
||||
|
||||
class BadRequestError(HTTPException):
|
||||
"""Invalid request data."""
|
||||
|
||||
def __init__(self, detail: str = "Invalid request") -> None:
|
||||
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
||||
|
||||
|
||||
class ValidationError(HTTPException):
|
||||
"""Validation failed."""
|
||||
|
||||
def __init__(self, detail: str = "Validation failed") -> None:
|
||||
super().__init__(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BadRequestError",
|
||||
"ConflictError",
|
||||
"ForbiddenError",
|
||||
"NotFoundError",
|
||||
"UnauthorizedError",
|
||||
"ValidationError",
|
||||
]
|
||||
106
app/core/security.py
Normal file
106
app/core/security.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Security utilities for JWT and password hashing."""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
||||
|
||||
|
||||
def create_access_token(
|
||||
sub: str,
|
||||
org_id: str,
|
||||
org_role: str,
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""Create a JWT access token with org context."""
|
||||
if expires_delta is None:
|
||||
expires_delta = timedelta(minutes=settings.access_token_expire_minutes)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
expire = now + expires_delta
|
||||
|
||||
payload = {
|
||||
"sub": sub,
|
||||
"org_id": org_id,
|
||||
"org_role": org_role,
|
||||
"iss": settings.jwt_issuer,
|
||||
"aud": settings.jwt_audience,
|
||||
"jti": str(uuid4()),
|
||||
"iat": now,
|
||||
"exp": expire,
|
||||
}
|
||||
|
||||
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
"""Decode and validate a JWT access token.
|
||||
|
||||
Raises:
|
||||
JWTError: If token is invalid or expired.
|
||||
"""
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.jwt_secret_key,
|
||||
algorithms=[settings.jwt_algorithm],
|
||||
issuer=settings.jwt_issuer,
|
||||
audience=settings.jwt_audience,
|
||||
)
|
||||
|
||||
|
||||
def generate_refresh_token() -> str:
|
||||
"""Generate a secure random refresh token."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""Hash a refresh token for storage."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def get_refresh_token_expiry() -> datetime:
|
||||
"""Get expiry datetime for a new refresh token."""
|
||||
return datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days)
|
||||
|
||||
|
||||
class TokenPayload:
|
||||
"""Parsed JWT token payload."""
|
||||
|
||||
def __init__(self, payload: dict[str, Any]) -> None:
|
||||
self.user_id = UUID(payload["sub"])
|
||||
self.org_id = UUID(payload["org_id"])
|
||||
self.org_role = payload["org_role"]
|
||||
self.issuer = payload["iss"]
|
||||
self.audience = payload["aud"]
|
||||
self.jti = UUID(payload["jti"])
|
||||
self.issued_at = payload["iat"]
|
||||
self.expires_at = payload["exp"]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"JWTError",
|
||||
"TokenPayload",
|
||||
"create_access_token",
|
||||
"decode_access_token",
|
||||
"generate_refresh_token",
|
||||
"get_refresh_token_expiry",
|
||||
"hash_password",
|
||||
"hash_token",
|
||||
"verify_password",
|
||||
]
|
||||
80
app/db.py
Normal file
80
app/db.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Database connection management using asyncpg."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import asyncpg
|
||||
import redis.asyncio as redis
|
||||
|
||||
|
||||
class Database:
|
||||
"""Manages asyncpg connection pool."""
|
||||
|
||||
pool: asyncpg.Pool | None = None
|
||||
|
||||
async def connect(self, dsn: str) -> None:
|
||||
"""Create connection pool."""
|
||||
self.pool = await asyncpg.create_pool(
|
||||
dsn,
|
||||
min_size=5,
|
||||
max_size=20,
|
||||
command_timeout=60,
|
||||
)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Close connection pool."""
|
||||
if self.pool:
|
||||
await self.pool.close()
|
||||
|
||||
@asynccontextmanager
|
||||
async def connection(self) -> AsyncGenerator[asyncpg.Connection, None]:
|
||||
"""Acquire a connection from the pool."""
|
||||
if not self.pool:
|
||||
raise RuntimeError("Database not connected")
|
||||
async with self.pool.acquire() as conn:
|
||||
yield conn
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self) -> AsyncGenerator[asyncpg.Connection, None]:
|
||||
"""Acquire a connection with an active transaction."""
|
||||
if not self.pool:
|
||||
raise RuntimeError("Database not connected")
|
||||
async with self.pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
yield conn
|
||||
|
||||
|
||||
class RedisClient:
|
||||
"""Manages Redis connection."""
|
||||
|
||||
client: redis.Redis | None = None
|
||||
|
||||
async def connect(self, url: str) -> None:
|
||||
"""Create Redis connection."""
|
||||
self.client = redis.from_url(url, decode_responses=True)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Close Redis connection."""
|
||||
if self.client:
|
||||
await self.client.aclose()
|
||||
|
||||
async def ping(self) -> bool:
|
||||
"""Check if Redis is reachable."""
|
||||
if not self.client:
|
||||
return False
|
||||
try:
|
||||
await self.client.ping()
|
||||
return True
|
||||
except redis.RedisError:
|
||||
return False
|
||||
|
||||
|
||||
# Global instances
|
||||
db = Database()
|
||||
redis_client = RedisClient()
|
||||
|
||||
|
||||
async def get_conn() -> AsyncGenerator[asyncpg.Connection, None]:
|
||||
"""Dependency for getting a database connection."""
|
||||
async with db.connection() as conn:
|
||||
yield conn
|
||||
33
app/main.py
Normal file
33
app/main.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""FastAPI application entry point."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.v1 import health
|
||||
from app.config import settings
|
||||
from app.db import db, redis_client
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Manage application lifecycle - connect/disconnect resources."""
|
||||
# Startup
|
||||
await db.connect(settings.database_url)
|
||||
await redis_client.connect(settings.redis_url)
|
||||
yield
|
||||
# Shutdown
|
||||
await redis_client.disconnect()
|
||||
await db.disconnect()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="IncidentOps",
|
||||
description="Incident management API with multi-tenant org support",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(health.router, prefix=settings.api_v1_prefix, tags=["health"])
|
||||
Reference in New Issue
Block a user