From f3c679faabdb397d4248a0e40711ac12e873c99a Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 18:16:20 +0000 Subject: [PATCH] WIP checkpoint: Scaffold FastAPI backend project structure --- backend/app/routers/assets.py | 180 ++++++++++++++++++++++++++++++++++ backend/app/routers/auth.py | 134 +++++++++++++++++++++++++ backend/app/routers/users.py | 126 ++++++++++++++++++++++++ backend/app/routers/worlds.py | 151 ++++++++++++++++++++++++++++ 4 files changed, 591 insertions(+) create mode 100644 backend/app/routers/assets.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/routers/users.py create mode 100644 backend/app/routers/worlds.py diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py new file mode 100644 index 0000000..a3a4032 --- /dev/null +++ b/backend/app/routers/assets.py @@ -0,0 +1,180 @@ +"""Asset management router.""" + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database import get_db +from app.models.asset import Asset, AssetType +from app.models.user import User +from app.models.world import World +from pydantic import BaseModel, Field + +router = APIRouter() + + +# --- Pydantic schemas --- + +class AssetCreate(BaseModel): + """Schema for creating an asset reference.""" + name: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + asset_type: AssetType + world_id: Optional[int] = None + + +class AssetResponse(BaseModel): + """Schema for asset responses.""" + id: int + name: str + description: Optional[str] + asset_type: AssetType + file_path: str + file_size: Optional[int] + mime_type: Optional[str] + world_id: Optional[int] + owner_id: int + is_public: bool + created_at: str + updated_at: str + + class Config: + from_attributes = True + + +class AssetUpdate(BaseModel): + """Schema for updating an asset.""" + name: Optional[str] = None + description: Optional[str] = None + is_public: Optional[bool] = None + + +# --- Endpoints --- + +@router.post("/", response_model=AssetResponse, status_code=201) +async def create_asset( + payload: AssetCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Create a new asset reference.""" + # Verify world ownership if world_id is provided + if payload.world_id is not None: + world = await db.get(World, payload.world_id) + if world is None: + raise HTTPException(status_code=404, detail="World not found") + if world.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not owner of this world") + + asset = Asset( + name=payload.name, + description=payload.description, + asset_type=payload.asset_type, + file_path="", # Will be set after upload + owner_id=current_user.id, + world_id=payload.world_id, + is_public=False, + ) + db.add(asset) + await db.commit() + await db.refresh(asset) + return asset + + +@router.get("/", response_model=list[AssetResponse]) +async def list_assets( + world_id: Optional[int] = Query(None, description="Filter by world"), + asset_type: Optional[AssetType] = Query(None), + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List assets with optional filters.""" + stmt = select(Asset).where(Asset.owner_id == current_user.id) + + if world_id is not None: + stmt = stmt.where(Asset.world_id == world_id) + if asset_type is not None: + stmt = stmt.where(Asset.asset_type == asset_type) + + stmt = stmt.order_by(Asset.created_at.desc()).limit(limit).offset(offset) + result = await db.execute(stmt) + assets = result.scalars().all() + return assets + + +@router.get("/{asset_id}", response_model=AssetResponse) +async def get_asset( + asset_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get a single asset by ID.""" + asset = await db.get(Asset, asset_id) + if asset is None or asset.owner_id != current_user.id: + raise HTTPException(status_code=404, detail="Asset not found") + return asset + + +@router.patch("/{asset_id}", response_model=AssetResponse) +async def update_asset( + asset_id: int, + payload: AssetUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update an asset.""" + asset = await db.get(Asset, asset_id) + if asset is None or asset.owner_id != current_user.id: + raise HTTPException(status_code=404, detail="Asset not found") + + update_data = payload.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(asset, field, value) + + await db.commit() + await db.refresh(asset) + return asset + + +@router.delete("/{asset_id}", status_code=204) +async def delete_asset( + asset_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete an asset.""" + asset = await db.get(Asset, asset_id) + if asset is None or asset.owner_id != current_user.id: + raise HTTPException(status_code=404, detail="Asset not found") + + await db.delete(asset) + await db.commit() + + +@router.post("/{asset_id}/upload") +async def upload_asset_file( + asset_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Upload a file for an existing asset.""" + asset = await db.get(Asset, asset_id) + if asset is None or asset.owner_id != current_user.id: + raise HTTPException(status_code=404, detail="Asset not found") + + # TODO: Implement actual file upload logic (S3, local filesystem, etc.) + file_path = f"uploads/{asset_id}_{file.filename}" + asset.file_path = file_path + asset.mime_type = file.content_type + asset.file_size = 0 # Will be updated after actual write + + await db.commit() + await db.refresh(asset) + + return {"message": "File upload endpoint", "asset_id": asset_id, "file_path": file_path} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..49b22a5 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,134 @@ +"""Authentication router - login, register, token refresh.""" + +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from pydantic import BaseModel, EmailStr + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.config import settings +from app.database import get_db +from app.models.user import User +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +router = APIRouter() + + +# --- Pydantic schemas --- + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class RegisterRequest(BaseModel): + username: str + email: EmailStr + password: str + + +class LoginRequest(BaseModel): + username: str + password: str + + +# --- Helpers --- + +def _create_access_token(data: dict) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +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) + + +# --- Endpoints --- + +@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): + """Register a new user and return an access token.""" + # Check if username already exists + result = await db.execute(select(User).where(User.username == req.username)) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username already registered", + ) + + # Check if email already exists + result = await db.execute(select(User).where(User.email == req.email)) + if result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Email already registered", + ) + + user = User( + username=req.username, + email=req.email, + hashed_password=_get_password_hash(req.password), + ) + db.add(user) + await db.commit() + await db.refresh(user) + + access_token = _create_access_token({"sub": str(user.id)}) + return TokenResponse(access_token=access_token) + + +@router.post("/login", response_model=TokenResponse) +async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): + """Authenticate user and return an access token.""" + result = await db.execute(select(User).where(User.username == req.username)) + user = result.scalar_one_or_none() + + if not user or not _verify_password(req.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is inactive", + ) + + access_token = _create_access_token({"sub": str(user.id)}) + return TokenResponse(access_token=access_token) + + +@router.get("/me") +async def get_current_user( + # In production, extract from Authorization header via OAuth2PasswordBearer + # For now, accept a user_id query param for simplicity + user_id: int, + db: AsyncSession = Depends(get_db), +): + """Get current user profile.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return { + "id": user.id, + "username": user.username, + "email": user.email, + "is_active": user.is_active, + "created_at": user.created_at, + } diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..ccdbc35 --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,126 @@ +"""User management router.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, EmailStr +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.user import User + +router = APIRouter() + + +# ── Pydantic schemas ──────────────────────────────────────────────── + +class UserCreate(BaseModel): + username: str + email: EmailStr + password: str + + +class UserResponse(BaseModel): + id: int + username: str + email: str + is_active: bool + + model_config = {"from_attributes": True} + + +class UserUpdate(BaseModel): + email: EmailStr | None = None + is_active: bool | None = None + + +# ── Endpoints ──────────────────────────────────────────────────────── + +@router.post( + "/", + response_model=UserResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_user(user_in: UserCreate, db: Annotated[AsyncSession, Depends(get_db)]): + """Register a new user.""" + # Check uniqueness of username + existing = await db.execute(select(User).where(User.username == user_in.username)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Username already taken") + + # Check uniqueness of email + existing = await db.execute(select(User).where(User.email == user_in.email)) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already registered") + + from app.routers.auth import hash_password + hashed = hash_password(user_in.password) + + user = User( + username=user_in.username, + email=user_in.email, + hashed_password=hashed, + ) + db.add(user) + await db.commit() + await db.refresh(user) + return user + + +@router.get("/me", response_model=UserResponse) +async def get_current_user( + db: Annotated[AsyncSession, Depends(get_db)], + # current_user would come from JWT dependency in real usage +): + """Get the currently authenticated user (placeholder).""" + raise HTTPException(status_code=501, detail="Not implemented") + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user( + user_id: int, + db: Annotated[AsyncSession, Depends(get_db)], +): + """Get a user by ID.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.patch("/{user_id}", response_model=UserResponse) +async def update_user( + user_id: int, + user_update: UserUpdate, + db: Annotated[AsyncSession, Depends(get_db)], +): + """Update a user.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user_update.email is not None: + user.email = user_update.email + if user_update.is_active is not None: + user.is_active = user_update.is_active + + await db.commit() + await db.refresh(user) + return user + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: int, + db: Annotated[AsyncSession, Depends(get_db)], +): + """Delete a user.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + await db.delete(user) + await db.commit() diff --git a/backend/app/routers/worlds.py b/backend/app/routers/worlds.py new file mode 100644 index 0000000..43d4209 --- /dev/null +++ b/backend/app/routers/worlds.py @@ -0,0 +1,151 @@ +"""Worlds router - CRUD for game worlds.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database import get_db +from app.models.world import World, WorldVisibility + +router = APIRouter() + + +async def get_current_user(): + """Placeholder dependency for current user auth.""" + # TODO: Implement JWT auth dependency + return {"id": 1, "username": "testuser", "email": "test@example.com"} + + +@router.get("/") +async def list_worlds( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + visibility: WorldVisibility | None = None, + db: Annotated[AsyncSession, Depends(get_db)] = Depends(), +): + """List worlds with optional visibility filter.""" + query = select(World).options(selectinload(World.owner)) + if visibility: + query = query.where(World.visibility == visibility) + query = query.offset(skip).limit(limit).order_by(World.created_at.desc()) + result = await db.execute(query) + worlds = result.scalars().all() + return [ + { + "id": w.id, + "name": w.name, + "description": w.description, + "visibility": w.visibility.value if w.visibility else None, + "owner_id": w.owner_id, + "created_at": w.created_at.isoformat() if w.created_at else None, + } + for w in worlds + ] + + +@router.get("/{world_id}") +async def get_world( + world_id: int, + db: Annotated[AsyncSession, Depends(get_db)] = Depends(), +): + """Get a single world by ID.""" + result = await db.execute( + select(World).where(World.id == world_id).options(selectinload(World.owner)) + ) + world = result.scalar_one_or_none() + if not world: + raise HTTPException(status_code=404, detail="World not found") + return { + "id": world.id, + "name": world.name, + "description": world.description, + "visibility": world.visibility.value if world.visibility else None, + "owner_id": world.owner_id, + "created_at": world.created_at.isoformat() if world.created_at else None, + } + + +@router.post("/") +async def create_world( + world_data: dict, + current_user=Depends(get_current_user), + db: Annotated[AsyncSession, Depends(get_db)] = Depends(), +): + """Create a new world.""" + name = world_data.get("name") + if not name: + raise HTTPException(status_code=400, detail="World name is required") + + world = World( + name=name, + description=world_data.get("description"), + owner_id=current_user["id"], + visibility=WorldVisibility( + world_data.get("visibility", "public") + ), + ) + db.add(world) + await db.commit() + await db.refresh(world) + + return { + "id": world.id, + "name": world.name, + "description": world.description, + "visibility": world.visibility.value, + "owner_id": world.owner_id, + "created_at": world.created_at.isoformat() if world.created_at else None, + } + + +@router.put("/{world_id}") +async def update_world( + world_id: int, + world_data: dict, + current_user=Depends(get_current_user), + db: Annotated[AsyncSession, Depends(get_db)] = Depends(), +): + """Update an existing world.""" + result = await db.execute(select(World).where(World.id == world_id)) + world = result.scalar_one_or_none() + if not world: + raise HTTPException(status_code=404, detail="World not found") + + if world_data.get("name"): + world.name = world_data["name"] + if world_data.get("description") is not None: + world.description = world_data["description"] + if world_data.get("visibility"): + world.visibility = WorldVisibility(world_data["visibility"]) + + await db.commit() + await db.refresh(world) + + return { + "id": world.id, + "name": world.name, + "description": world.description, + "visibility": world.visibility.value if world.visibility else None, + "owner_id": world.owner_id, + "created_at": world.created_at.isoformat() if world.created_at else None, + } + + +@router.delete("/{world_id}") +async def delete_world( + world_id: int, + current_user=Depends(get_current_user), + db: Annotated[AsyncSession, Depends(get_db)] = Depends(), +): + """Delete a world.""" + result = await db.execute(select(World).where(World.id == world_id)) + world = result.scalar_one_or_none() + if not world: + raise HTTPException(status_code=404, detail="World not found") + + await db.delete(world) + await db.commit() + return {"detail": "World deleted"}