From d04730edd820c5452f87d2c1b8cfcde16d947ad6 Mon Sep 17 00:00:00 2001 From: Tom Trappmann Date: Tue, 23 Dec 2025 17:17:33 +0100 Subject: [PATCH] init --- .env.example | 12 ++++++++ .gitignore | 5 ++++ README.md | 59 ++++++++++++++++++++++++++++++++++++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/api_v1.py | 9 ++++++ app/api/deps.py | 44 ++++++++++++++++++++++++++++ app/api/routes/__init__.py | 3 ++ app/api/routes/auth.py | 31 ++++++++++++++++++++ app/api/routes/health.py | 8 ++++++ app/api/routes/users.py | 12 ++++++++ app/core/__init__.py | 0 app/core/config.py | 37 ++++++++++++++++++++++++ app/core/database.py | 9 ++++++ app/core/security.py | 27 +++++++++++++++++ app/crud/__init__.py | 0 app/crud/user.py | 30 +++++++++++++++++++ app/db/__init__.py | 0 app/db/base.py | 5 ++++ app/db/init_db.py | 7 +++++ app/main.py | 27 +++++++++++++++++ app/models/__init__.py | 3 ++ app/models/user.py | 13 +++++++++ app/schemas/__init__.py | 0 app/schemas/token.py | 6 ++++ app/schemas/user.py | 17 +++++++++++ docker-compose.yml | 14 +++++++++ requirements.txt | 9 ++++++ 28 files changed, 387 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/api_v1.py create mode 100644 app/api/deps.py create mode 100644 app/api/routes/__init__.py create mode 100644 app/api/routes/auth.py create mode 100644 app/api/routes/health.py create mode 100644 app/api/routes/users.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/database.py create mode 100644 app/core/security.py create mode 100644 app/crud/__init__.py create mode 100644 app/crud/user.py create mode 100644 app/db/__init__.py create mode 100644 app/db/base.py create mode 100644 app/db/init_db.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/user.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/token.py create mode 100644 app/schemas/user.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..acbdbf7 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +PROJECT_NAME="NautilusDesk API" +SECRET_KEY="change-me" +ACCESS_TOKEN_EXPIRE_MINUTES=60 + +POSTGRES_SERVER="localhost" +POSTGRES_PORT=5432 +POSTGRES_USER="postgres" +POSTGRES_PASSWORD="postgres" +POSTGRES_DB="nautilusdesk" +# DATABASE_URL="postgresql+psycopg2://user:pass@localhost:5432/nautilusdesk" + +# BACKEND_CORS_ORIGINS="http://localhost:3000,http://localhost:5173" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4773904 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +.env +postgres_data/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5597b54 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# NautilusDesk Backend (FastAPI Starter) + +FastAPI starter with PostgreSQL, JWT auth, and a clean project structure. + +## Quickstart + +1. Create a virtualenv and install deps: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +2. Create your `.env` file: + +```bash +cp .env.example .env +``` + +3. Start Postgres (example docker-compose below), or point to your own DB in `.env`. + +4. Run the API: + +```bash +uvicorn app.main:app --reload +``` + +## Docker Postgres (optional) + +```yaml +version: "3.9" +services: + db: + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: nautilusdesk + volumes: + - postgres_data:/var/lib/postgresql/data +volumes: + postgres_data: +``` + +## Auth flow + +- `POST /api/v1/auth/register` with JSON `{ "email": "user@example.com", "password": "secret" }` +- `POST /api/v1/auth/login` with form fields `username` (email) + `password` +- Use `Authorization: Bearer ` for protected routes + +## Endpoints + +- `GET /api/v1/health` +- `POST /api/v1/auth/register` +- `POST /api/v1/auth/login` +- `GET /api/v1/users/me` 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/api_v1.py b/app/api/api_v1.py new file mode 100644 index 0000000..bbf7d54 --- /dev/null +++ b/app/api/api_v1.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter + +from app.api.routes import auth, health, users + +api_router = APIRouter() + +api_router.include_router(health.router, tags=["health"]) +api_router.include_router(auth.router) +api_router.include_router(users.router) diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..b0279b3 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,44 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.database import SessionLocal +from app.crud import user as user_crud +from app.models.user import User + +settings = get_settings() + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def get_current_user( + db: Session = Depends(get_db), + token: str = Depends(oauth2_scheme), +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + subject = payload.get("sub") + if subject is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + user = user_crud.get_by_email(db, subject) + if not user: + raise credentials_exception + return user diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..43903c0 --- /dev/null +++ b/app/api/routes/__init__.py @@ -0,0 +1,3 @@ +from app.api.routes import auth, health, users + +__all__ = ["auth", "health", "users"] diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py new file mode 100644 index 0000000..27673d4 --- /dev/null +++ b/app/api/routes/auth.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app.api import deps +from app.core.security import create_access_token +from app.crud import user as user_crud +from app.schemas.token import Token +from app.schemas.user import UserCreate, UserRead + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED) +def register(user_in: UserCreate, db: Session = Depends(deps.get_db)): + existing = user_crud.get_by_email(db, user_in.email) + if existing: + raise HTTPException(status_code=400, detail="Email already registered") + return user_crud.create(db, user_in) + + +@router.post("/login", response_model=Token) +def login( + db: Session = Depends(deps.get_db), + form_data: OAuth2PasswordRequestForm = Depends(), +): + user = user_crud.authenticate(db, form_data.username, form_data.password) + if not user: + raise HTTPException(status_code=400, detail="Incorrect email or password") + access_token = create_access_token(subject=user.email) + return {"access_token": access_token, "token_type": "bearer"} diff --git a/app/api/routes/health.py b/app/api/routes/health.py new file mode 100644 index 0000000..54bc2dd --- /dev/null +++ b/app/api/routes/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +def health_check(): + return {"status": "ok"} diff --git a/app/api/routes/users.py b/app/api/routes/users.py new file mode 100644 index 0000000..bf357e6 --- /dev/null +++ b/app/api/routes/users.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter, Depends + +from app.api import deps +from app.models.user import User +from app.schemas.user import UserRead + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/me", response_model=UserRead) +def read_users_me(current_user: User = Depends(deps.get_current_user)): + return current_user diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..e42c2d3 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,37 @@ +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_ignore_empty=True) + + PROJECT_NAME: str = "NautilusDesk API" + SECRET_KEY: str = "change-me" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 + + POSTGRES_SERVER: str = "localhost" + POSTGRES_PORT: int = 5432 + POSTGRES_USER: str = "postgres" + POSTGRES_PASSWORD: str = "postgres" + POSTGRES_DB: str = "nautilusdesk" + DATABASE_URL: str | None = None + + BACKEND_CORS_ORIGINS: List[str] = [] + + @property + def database_url(self) -> str: + if self.DATABASE_URL: + return self.DATABASE_URL + return ( + f"postgresql+psycopg2://{self.POSTGRES_USER}:" + f"{self.POSTGRES_PASSWORD}@{self.POSTGRES_SERVER}:" + f"{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + ) + + +@lru_cache + +def get_settings() -> Settings: + return Settings() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..0785b64 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,9 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.core.config import get_settings + +settings = get_settings() + +engine = create_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..4aa331f --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,27 @@ +from datetime import datetime, timedelta, timezone + +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import get_settings + +settings = get_settings() + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +ALGORITHM = "HS256" + + +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: timedelta | None = None) -> str: + if expires_delta is None: + expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(timezone.utc) + expires_delta + to_encode = {"exp": expire, "sub": subject} + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud/user.py b/app/crud/user.py new file mode 100644 index 0000000..24015a2 --- /dev/null +++ b/app/crud/user.py @@ -0,0 +1,30 @@ +from sqlalchemy.orm import Session + +from app.core.security import get_password_hash, verify_password +from app.models.user import User +from app.schemas.user import UserCreate + + +def get_by_email(db: Session, email: str) -> User | None: + return db.query(User).filter(User.email == email).first() + + +def create(db: Session, user_in: UserCreate) -> User: + user = User( + email=user_in.email, + hashed_password=get_password_hash(user_in.password), + is_active=True, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def authenticate(db: Session, email: str, password: str) -> User | None: + user = get_by_email(db, email) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/app/db/init_db.py b/app/db/init_db.py new file mode 100644 index 0000000..69dd3ea --- /dev/null +++ b/app/db/init_db.py @@ -0,0 +1,7 @@ +from app.core.database import engine +from app.db.base import Base +from app.models import user # noqa: F401 + + +def init_db() -> None: + Base.metadata.create_all(bind=engine) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e1e1aa3 --- /dev/null +++ b/app/main.py @@ -0,0 +1,27 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.api_v1 import api_router +from app.core.config import get_settings +from app.db.init_db import init_db + +settings = get_settings() + +app = FastAPI(title=settings.PROJECT_NAME) + +if settings.BACKEND_CORS_ORIGINS: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.BACKEND_CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + +@app.on_event("startup") +def on_startup(): + init_db() + + +app.include_router(api_router, prefix="/api/v1") 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..c95a86b --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,13 @@ +from sqlalchemy import Boolean, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + hashed_password: Mapped[str] = mapped_column(String(255)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/token.py b/app/schemas/token.py new file mode 100644 index 0000000..7f3cf36 --- /dev/null +++ b/app/schemas/token.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..f60ee96 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, EmailStr + + +class UserBase(BaseModel): + email: EmailStr + is_active: bool = True + + +class UserCreate(UserBase): + password: str + + +class UserRead(UserBase): + id: int + + class Config: + from_attributes = True diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fd78a44 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: "3.9" +services: + db: + image: postgres:16 + ports: + - "5432:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: nautilusdesk + volumes: + - postgres_data:/var/lib/postgresql/data +volumes: + postgres_data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..56a65f6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +SQLAlchemy==2.0.34 +psycopg2-binary==2.9.9 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +pydantic-settings==2.5.2 +python-multipart==0.0.12 +email-validator==2.2.0