This commit is contained in:
Tom Trappmann
2025-12-23 17:17:33 +01:00
commit d04730edd8
28 changed files with 387 additions and 0 deletions

12
.env.example Normal file
View File

@@ -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"

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.pyc
.env
postgres_data/

59
README.md Normal file
View File

@@ -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 <token>` for protected routes
## Endpoints
- `GET /api/v1/health`
- `POST /api/v1/auth/register`
- `POST /api/v1/auth/login`
- `GET /api/v1/users/me`

0
app/__init__.py Normal file
View File

0
app/api/__init__.py Normal file
View File

9
app/api/api_v1.py Normal file
View File

@@ -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)

44
app/api/deps.py Normal file
View File

@@ -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

View File

@@ -0,0 +1,3 @@
from app.api.routes import auth, health, users
__all__ = ["auth", "health", "users"]

31
app/api/routes/auth.py Normal file
View File

@@ -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"}

8
app/api/routes/health.py Normal file
View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
def health_check():
return {"status": "ok"}

12
app/api/routes/users.py Normal file
View File

@@ -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

0
app/core/__init__.py Normal file
View File

37
app/core/config.py Normal file
View File

@@ -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()

9
app/core/database.py Normal file
View File

@@ -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)

27
app/core/security.py Normal file
View File

@@ -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)

0
app/crud/__init__.py Normal file
View File

30
app/crud/user.py Normal file
View File

@@ -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

0
app/db/__init__.py Normal file
View File

5
app/db/base.py Normal file
View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

7
app/db/init_db.py Normal file
View File

@@ -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)

27
app/main.py Normal file
View File

@@ -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")

3
app/models/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from app.models.user import User
__all__ = ["User"]

13
app/models/user.py Normal file
View File

@@ -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)

0
app/schemas/__init__.py Normal file
View File

6
app/schemas/token.py Normal file
View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str = "bearer"

17
app/schemas/user.py Normal file
View File

@@ -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

14
docker-compose.yml Normal file
View File

@@ -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:

9
requirements.txt Normal file
View File

@@ -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