diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9ca6bd8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +*.db +.env +.env.* +.venv +venv +.git +.gitignore +Dockerfile +**/__pycache__ +**/*.py[cod] +**/*.log +alembic/versions/*.pyc \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1e89aca --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + - name: Lint + run: | + ruff check . + black --check . + - name: Test + env: + DATABASE_URL: sqlite:///./test.db + run: | + pytest -q \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8959d96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,84 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.env +.venv +venv/ +ENV/ + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +pytest_cache/ +.pytest_cache/ + +# MyPy / Ruff / Pylint +.mypy_cache/ +.ruff_cache/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +*.env +.env.* + +# VS Code +.vscode/ + +# MacOS +.DS_Store + +# Logs +logs/ +*.log + +# SQLite databases +*.db +app.db + +docker-data/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cbd20d1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1.7-labs +FROM python:3.11-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +# Install system deps +RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY app ./app +COPY alembic.ini ./alembic.ini +COPY alembic ./alembic + +# Non-root user +RUN useradd -u 10001 -r -s /sbin/nologin appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 + +ENV PORT=8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7f2ddbe --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +PYTHON ?= python3 +PIP ?= $(PYTHON) -m pip +VENV ?= .venv +ACTIVATE = . $(VENV)/bin/activate + +.PHONY: venv install dev run fmt lint test migrate revision alembic-upgrade + +venv: + $(PYTHON) -m venv $(VENV) + $(ACTIVATE); $(PIP) install --upgrade pip + +install: venv + $(ACTIVATE); $(PIP) install -r requirements.txt + +dev: venv + $(ACTIVATE); $(PIP) install -r requirements-dev.txt + +run: + $(ACTIVATE); UVICORN_WORKERS=$${UVICORN_WORKERS:-2} uvicorn app.main:app --host 0.0.0.0 --port $${PORT:-8000} + +fmt: + $(ACTIVATE); ruff check --fix . + $(ACTIVATE); black . + +lint: + $(ACTIVATE); ruff check . + $(ACTIVATE); black --check . + +test: + DATABASE_URL=sqlite:///./test.db $(ACTIVATE); pytest + +revision: + $(ACTIVATE); alembic revision --autogenerate -m "auto" + +migrate: + $(ACTIVATE); alembic upgrade head + +alembic-upgrade: + $(ACTIVATE); alembic upgrade head \ No newline at end of file diff --git a/README.md b/README.md index 67a92ab..72b52a1 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,72 @@ -- šŸ‘‹ Hi, I’m @Qomserver -- šŸ‘€ I’m interested in ... -- 🌱 I’m currently learning ... -- šŸ’žļø I’m looking to collaborate on ... -- šŸ“« How to reach me ... -- šŸ˜„ Pronouns: ... -- ⚔ Fun fact: ... - - +# Production-Ready FastAPI Service + +A secure, scalable, and production-ready FastAPI service with JWT auth, SQLAlchemy, Alembic migrations, rate limiting, metrics, Docker, CI, and Kubernetes manifests. + +## Features +- FastAPI with pydantic v2 +- JWT authentication (access tokens) +- SQLAlchemy 2.0 ORM + Alembic migrations +- Rate limiting (slowapi) +- Prometheus metrics at `/metrics` +- Health probes at `/health/live` and `/health/ready` +- Dockerfile and docker-compose for local Postgres +- CI (lint + tests) via GitHub Actions +- Kubernetes manifests + +## Quickstart (local) +1. Python 3.11+ +2. Create virtualenv and install deps: + ```bash + make dev + ``` +3. Run API (SQLite by default): + ```bash + make run + ``` +4. Open docs: `http://localhost:8000/docs` + +## Environment +Copy `.env.example` to `.env` and adjust as needed. Key vars: +- `SECRET_KEY` (required in production) +- `DATABASE_URL` (Postgres in prod; SQLite default for dev/tests) +- `FIRST_SUPERUSER_EMAIL`, `FIRST_SUPERUSER_PASSWORD` (optional bootstrap) +- `ALLOW_USER_REGISTRATION` (default: true) + +## Docker (local with Postgres) +```bash +docker compose up --build +``` + +## Migrations +```bash +make migrate # apply +make revision # create new from models +``` + +## Tests +```bash +make test +``` + +## Deploy +- Build container image with `Dockerfile` +- Use `k8s/` manifests or integrate into your platform (ECS, GKE, etc.) +- Configure probes and resources as shown in `k8s/deployment.yaml` + +## Security +- Set strong `SECRET_KEY` +- Run behind HTTPS (ingress/ALB) +- Review CORS settings in `app/main.py` +- Rotate tokens, limit exposure + +## Structure +``` +app/ + api/ routes, deps + core/ config, security + db/ engine, base + models/ + schemas/ + middleware/ + utils/ +``` diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..542b12b --- /dev/null +++ b/alembic.ini @@ -0,0 +1,35 @@ +[alembic] +script_location = alembic +sqlalchemy.url = %(DATABASE_URL)s + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..d8ebc8e --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,50 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context +from app import models # noqa: F401 - ensure models loaded +from app.core.config import settings +from app.db.base import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", settings.database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/versions/202407010000_init.py b/alembic/versions/202407010000_init.py new file mode 100644 index 0000000..6e9db9f --- /dev/null +++ b/alembic/versions/202407010000_init.py @@ -0,0 +1,31 @@ +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "202407010000" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("full_name", sa.String(length=255), nullable=True), + sa.Column("hashed_password", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_users_id", "users", ["id"], unique=False) + op.create_index("ix_users_email", "users", ["email"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_users_email", table_name="users") + op.drop_index("ix_users_id", table_name="users") + op.drop_table("users") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..eaca600 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,60 @@ +from typing import Annotated + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.db.session import get_session +from app.models.user import User + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") + + +def get_db() -> Session: + with get_session() as db: + yield db + + +def get_user_by_email(db: Session, email: str) -> User | None: + return db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none() + + +async def get_current_user( + token: Annotated[str, Depends(oauth2_scheme)], + db: Annotated[Session, Depends(get_db)], +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + ) + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm]) + email: str | None = payload.get("sub") + if email is None: + raise credentials_exception + except Exception: # noqa: BLE001 + raise credentials_exception from None + + user = get_user_by_email(db, email) + if not user: + raise credentials_exception + return user + + +async def get_current_active_user( + user: Annotated[User, Depends(get_current_user)], +) -> User: + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return user + + +async def get_current_active_superuser( + user: Annotated[User, Depends(get_current_active_user)], +) -> User: + if not user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough privileges") + return user diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py new file mode 100644 index 0000000..7950240 --- /dev/null +++ b/app/api/routes/auth.py @@ -0,0 +1,52 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app.api.deps import get_db, get_user_by_email +from app.core.config import settings +from app.core.security import create_access_token, get_password_hash, verify_password +from app.middleware.rate_limit import limiter +from app.models.user import User +from app.schemas.user import Token, UserCreate + +router = APIRouter() + + +@router.post("/login", response_model=Token) +@limiter.limit(settings.rate_limit_auth) +def login_access_token( + request: Request, + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + db: Annotated[Session, Depends(get_db)], +) -> Token: + user = get_user_by_email(db, form_data.username) + if not user or not verify_password(form_data.password, user.hashed_password): + raise HTTPException(status_code=400, detail="Incorrect email or password") + access_token = create_access_token(subject=user.email) + return Token(access_token=access_token) + + +@router.post("/register", response_model=Token) +@limiter.limit(settings.rate_limit_auth) +def register(request: Request, user_in: UserCreate, db: Annotated[Session, Depends(get_db)]) -> Token: + if not settings.allow_user_registration: + raise HTTPException(status_code=403, detail="Registration disabled") + + existing = get_user_by_email(db, user_in.email) + if existing: + raise HTTPException(status_code=400, detail="Email already registered") + + user = User( + email=user_in.email.lower(), + full_name=user_in.full_name, + hashed_password=get_password_hash(user_in.password), + is_active=True, + is_superuser=False, + ) + db.add(user) + db.commit() + + token = create_access_token(subject=user.email) + return Token(access_token=token) diff --git a/app/api/routes/health.py b/app/api/routes/health.py new file mode 100644 index 0000000..c6b4959 --- /dev/null +++ b/app/api/routes/health.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter +from sqlalchemy import text + +from app.db.session import get_engine + +router = APIRouter() + + +@router.get("/live") +async def live() -> dict[str, str]: + return {"status": "ok"} + + +@router.get("/ready") +async def ready() -> dict[str, str]: + engine = get_engine() + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + return {"status": "ready"} diff --git a/app/api/routes/users.py b/app/api/routes/users.py new file mode 100644 index 0000000..b4c4330 --- /dev/null +++ b/app/api/routes/users.py @@ -0,0 +1,70 @@ +from typing import Annotated, List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import ( + get_current_active_superuser, + get_current_active_user, + get_db, +) +from app.core.security import get_password_hash +from app.models.user import User +from app.schemas.user import UserRead, UserUpdate + +router = APIRouter() + + +@router.get("/me", response_model=UserRead) +async def read_me( + current_user: Annotated[User, Depends(get_current_active_user)], +) -> User: + return current_user + + +@router.get("/", response_model=List[UserRead]) +async def list_users( + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_superuser)], +) -> list[User]: + users = db.execute(select(User).order_by(User.id)).scalars().all() + return users + + +@router.get("/{user_id}", response_model=UserRead) +async def read_user( + user_id: int, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_superuser)], +) -> User: + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.patch("/{user_id}", response_model=UserRead) +async def update_user( + user_id: int, + user_in: UserUpdate, + db: Annotated[Session, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_superuser)], +) -> User: + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user_in.full_name is not None: + user.full_name = user_in.full_name + if user_in.password is not None: + user.hashed_password = get_password_hash(user_in.password) + if user_in.is_active is not None: + user.is_active = user_in.is_active + if user_in.is_superuser is not None: + user.is_superuser = user_in.is_superuser + + db.add(user) + db.commit() + db.refresh(user) + return user diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..8228aaf --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,41 @@ +from functools import lru_cache +from typing import List + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=(".env",), env_prefix="", case_sensitive=False) + + app_name: str = "FastAPI Service" + version: str = "0.1.0" + environment: str = "development" # development|staging|production + debug: bool = False + enable_docs: bool = True + + secret_key: str = "change-me" + access_token_expire_minutes: int = 60 * 24 + jwt_algorithm: str = "HS256" + + database_url: str = "sqlite:///./app.db" + + cors_allow_origins: List[str] = ["*"] + + # Registration + allow_user_registration: bool = True + + # Rate limiting + rate_limit_default: str = "100/minute" + rate_limit_auth: str = "20/minute" + + # Bootstrap superuser + first_superuser_email: str | None = None + first_superuser_password: str | None = None + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..ec54462 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,61 @@ +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +import jwt +from passlib.context import CryptContext +from sqlalchemy import select + +from app.core.config import settings +from app.db.session import get_session +from app.models.user import User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(subject: str, expires_delta_minutes: Optional[int] = None) -> str: + to_encode: dict[str, Any] = { + "sub": subject, + "iat": int(datetime.now(tz=timezone.utc).timestamp()), + } + expire_minutes = ( + expires_delta_minutes + if expires_delta_minutes is not None + else settings.access_token_expire_minutes + ) + expire = datetime.now(tz=timezone.utc) + timedelta(minutes=expire_minutes) + to_encode.update({"exp": int(expire.timestamp())}) + encoded_jwt = jwt.encode( + to_encode, + settings.secret_key, + algorithm=settings.jwt_algorithm, + ) + return encoded_jwt + + +def bootstrap_initial_superuser() -> None: + if not settings.first_superuser_email or not settings.first_superuser_password: + return + + with get_session() as db: + existing = db.execute( + select(User).where(User.email == settings.first_superuser_email) + ).scalar_one_or_none() + if existing: + return + user = User( + email=settings.first_superuser_email.lower(), + full_name="admin", + hashed_password=get_password_hash(settings.first_superuser_password), + is_active=True, + is_superuser=True, + ) + db.add(user) + db.commit() diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..b2eec5d --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,15 @@ +from datetime import datetime + +from sqlalchemy import func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column(default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column( + default=func.now(), onupdate=func.now(), nullable=False + ) diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..44d5f44 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,55 @@ +from contextlib import contextmanager +from typing import Generator, Optional + +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import settings +from app.db.base import Base + + +class _DBState: + engine: Optional[Engine] = None + session_factory: Optional[sessionmaker] = None + schema_initialized: bool = False + + +def get_engine() -> Engine: + if _DBState.engine is None: + connect_args = {} + if settings.database_url.startswith("sqlite"): + connect_args = {"check_same_thread": False} + _DBState.engine = create_engine( + settings.database_url, + pool_pre_ping=True, + connect_args=connect_args, + ) + return _DBState.engine + + +def create_session_factory(engine: Optional[Engine] = None) -> None: + eng = engine or get_engine() + _DBState.session_factory = sessionmaker(autocommit=False, autoflush=False, bind=eng) + + +def _ensure_sqlite_schema_initialized() -> None: + if not settings.database_url.startswith("sqlite"): + return + if not _DBState.schema_initialized: + engine = get_engine() + Base.metadata.create_all(engine) + _DBState.schema_initialized = True + + +@contextmanager +def get_session() -> Generator[Session, None, None]: + if _DBState.session_factory is None: + create_session_factory() + _ensure_sqlite_schema_initialized() + assert _DBState.session_factory is not None + db = _DBState.session_factory() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..7640e58 --- /dev/null +++ b/app/main.py @@ -0,0 +1,98 @@ +import os +from contextlib import asynccontextmanager +from typing import Iterator + +import structlog +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from prometheus_client import ( + CONTENT_TYPE_LATEST, + CollectorRegistry, + generate_latest, + multiprocess as prom_multiproc, +) +from slowapi.errors import RateLimitExceeded +from starlette.responses import Response + +from app.api.routes.auth import router as auth_router +from app.api.routes.health import router as health_router +from app.api.routes.users import router as users_router +from app.core.config import settings +from app.core.security import bootstrap_initial_superuser +from app.db.base import Base +from app.db.session import create_session_factory, get_engine +from app.middleware.logging import configure_logging +from app.middleware.rate_limit import ( + SlowAPIMiddleware, + limiter, + rate_limit_exceeded_handler, +) +from app import models # noqa: F401 # ensure models are imported and registered + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> Iterator[None]: + configure_logging() + logger = structlog.get_logger() + + # Initialize DB connection pool and session factory + engine = get_engine() + create_session_factory(engine) + + # Auto-create tables in SQLite (dev/test convenience) + if settings.database_url.startswith("sqlite"): + Base.metadata.create_all(engine) + + # Bootstrap initial superuser if provided via env + try: + bootstrap_initial_superuser() + except Exception as exc: # noqa: BLE001 + logger.error("bootstrap_superuser_failed", error=str(exc)) + + yield + + # On shutdown + try: + engine.dispose() + except Exception as exc: # noqa: BLE001 + logger.error("engine_dispose_failed", error=str(exc)) + + +app = FastAPI( + title=settings.app_name, + version=settings.version, + docs_url="/docs" if settings.enable_docs else None, + redoc_url=None, + lifespan=lifespan, +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_allow_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Rate limiting +app.state.limiter = limiter +app.add_middleware(SlowAPIMiddleware) +app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) + +# Routers +app.include_router(health_router, prefix="/health", tags=["health"]) +app.include_router(auth_router, prefix="/auth", tags=["auth"]) +app.include_router(users_router, prefix="/users", tags=["users"]) + + +# Prometheus metrics endpoint +@app.get("/metrics") +def metrics() -> Response: + if "PROMETHEUS_MULTIPROC_DIR" in os.environ: + registry = CollectorRegistry() + prom_multiproc.MultiProcessCollector(registry) + data = generate_latest(registry) + else: + data = generate_latest() + return Response(data, media_type=CONTENT_TYPE_LATEST) diff --git a/app/middleware/logging.py b/app/middleware/logging.py new file mode 100644 index 0000000..ffa306e --- /dev/null +++ b/app/middleware/logging.py @@ -0,0 +1,30 @@ +import logging +import sys + +import structlog + + +def configure_logging() -> None: + timestamper = structlog.processors.TimeStamper(fmt="iso", utc=True) + + processors = [ + structlog.contextvars.merge_contextvars, + timestamper, + structlog.processors.add_log_level, + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.dict_tracebacks, + structlog.processors.JSONRenderer(), + ] + + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=logging.INFO, + ) + + structlog.configure( + processors=processors, + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, + ) diff --git a/app/middleware/rate_limit.py b/app/middleware/rate_limit.py new file mode 100644 index 0000000..9b7904b --- /dev/null +++ b/app/middleware/rate_limit.py @@ -0,0 +1,19 @@ +from fastapi import Request +from fastapi.responses import JSONResponse +from slowapi import Limiter +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware +from slowapi.util import get_remote_address + +from app.core.config import settings + +__all__ = ["limiter", "rate_limit_exceeded_handler", "SlowAPIMiddleware"] + +limiter = Limiter(key_func=get_remote_address, default_limits=[settings.rate_limit_default]) + + +def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded): + return JSONResponse( + status_code=429, + content={"detail": "Rate limit exceeded", "error": str(exc)}, + ) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..b2e47e8 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,3 @@ +from app.models.user import User + +__all__ = ["User"] diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..6119534 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,17 @@ +from typing import Optional + +from sqlalchemy import Boolean, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin + + +class User(TimestampMixin, Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False) + full_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..1ce0595 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,35 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field + + +class UserBase(BaseModel): + email: EmailStr + full_name: Optional[str] = None + + +class UserCreate(UserBase): + password: str = Field(min_length=8) + + +class UserUpdate(BaseModel): + full_name: Optional[str] = None + password: Optional[str] = Field(default=None, min_length=8) + is_active: Optional[bool] = None + is_superuser: Optional[bool] = None + + +class UserRead(UserBase): + id: int + is_active: bool + is_superuser: bool + created_at: datetime + + class Config: + from_attributes = True + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..935565b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: "3.9" + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: app + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + api: + build: . + environment: + DATABASE_URL: postgresql+psycopg://postgres:postgres@db:5432/app + SECRET_KEY: please-change-in-prod + UVICORN_WORKERS: 2 + ENABLE_DOCS: "true" + depends_on: + - db + ports: + - "8000:8000" + +volumes: + pgdata: \ No newline at end of file diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..c2e075e --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fastapi-service +spec: + replicas: 2 + selector: + matchLabels: + app: fastapi-service + template: + metadata: + labels: + app: fastapi-service + spec: + containers: + - name: api + image: your-registry/fastapi-service:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8000 + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: fastapi-secret + key: DATABASE_URL + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: fastapi-secret + key: SECRET_KEY + - name: ENABLE_DOCS + value: "false" + readinessProbe: + httpGet: + path: /health/ready + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health/live + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "512Mi" +--- +apiVersion: v1 +kind: Secret +metadata: + name: fastapi-secret +type: Opaque +stringData: + DATABASE_URL: postgresql+psycopg://user:password@postgres:5432/app + SECRET_KEY: please-change \ No newline at end of file diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..d48f30e --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: fastapi-service +spec: + selector: + app: fastapi-service + ports: + - protocol: TCP + port: 80 + targetPort: 8000 + type: ClusterIP \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f5302ce --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[tool.black] +line-length = 100 +target-version = ["py311"] + +[tool.ruff] +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP", "PL"] +ignore = [] + +[tool.ruff.lint.isort] +known-first-party = ["app"] +combine-as-imports = true + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-q" +asyncio_mode = "auto" + +[tool.coverage.run] +omit = [ + "tests/*", +] \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..03f586d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..c0097fa --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +-r requirements.txt + +black==24.4.2 +ruff==0.4.4 +pytest==8.2.0 +pytest-asyncio==0.23.6 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d5d4a45 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +fastapi==0.110.0 +uvicorn[standard]==0.30.1 +pydantic==2.9.2 +pydantic-settings==2.6.1 +SQLAlchemy==2.0.39 +alembic==1.13.1 +psycopg[binary]==3.2.9 +passlib[bcrypt]==1.7.4 +PyJWT==2.8.0 +python-multipart==0.0.9 +slowapi==0.1.9 +prometheus-client==0.20.0 +structlog==24.1.0 +httpx==0.27.0 +email-validator==2.2.0 \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..2cb0563 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,37 @@ +import os + +from fastapi import status +from fastapi.testclient import TestClient + +os.environ.setdefault("DATABASE_URL", "sqlite:///./test.db") + +from app.main import app # noqa: E402 + +client = TestClient(app) + + +def test_register_and_login_and_me(): + # register + r = client.post( + "/auth/register", + json={"email": "user@example.com", "password": "verysecurepassword", "full_name": "User"}, + ) + assert r.status_code == status.HTTP_200_OK, r.text + token = r.json()["access_token"] + assert token + + # login + r = client.post( + "/auth/login", + data={"username": "user@example.com", "password": "verysecurepassword"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert r.status_code == status.HTTP_200_OK, r.text + token2 = r.json()["access_token"] + assert token2 + + # me + r = client.get("/users/me", headers={"Authorization": f"Bearer {token2}"}) + assert r.status_code == status.HTTP_200_OK, r.text + me = r.json() + assert me["email"] == "user@example.com" diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..c99cdbe --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,22 @@ +import os + +from fastapi import status +from fastapi.testclient import TestClient + +os.environ.setdefault("DATABASE_URL", "sqlite:///./test.db") + +from app.main import app # noqa: E402 + +client = TestClient(app) + + +def test_live(): + r = client.get("/health/live") + assert r.status_code == status.HTTP_200_OK + assert r.json()["status"] == "ok" + + +def test_ready(): + r = client.get("/health/ready") + assert r.status_code == status.HTTP_200_OK + assert r.json()["status"] == "ready"