WIP checkpoint: Address review feedback: Scaffold FastAPI backend project structure

This commit is contained in:
2026-08-17 23:38:24 +00:00
parent 48dd177fa2
commit 48a43c44a5
5 changed files with 105 additions and 77 deletions
+2
View File
@@ -18,6 +18,8 @@ class Settings(BaseSettings):
REDIS_URL: str = "redis://localhost:6379/0" REDIS_URL: str = "redis://localhost:6379/0"
# JWT / Auth # JWT / Auth
# NOTE: production must override SECRET_KEY with a secure random value
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
SECRET_KEY: str = "change-me-to-a-secure-random-string" SECRET_KEY: str = "change-me-to-a-secure-random-string"
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
+6 -1
View File
@@ -35,7 +35,12 @@ async def get_current_user(
except JWTError: except JWTError:
raise credentials_exception raise credentials_exception
result = await db.execute(select(User).where(User.id == int(user_id))) try:
user_id_int = int(user_id)
except (ValueError, TypeError):
raise credentials_exception
result = await db.execute(select(User).where(User.id == user_id_int))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:
raise credentials_exception raise credentials_exception
+18 -5
View File
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db from app.database import get_db
from app.dependencies import CurrentUser from app.dependencies import CurrentUser
from app.models.asset import Asset, AssetType from app.models.asset import Asset, AssetType
@@ -40,8 +41,7 @@ class AssetResponse(BaseModel):
created_at: str created_at: str
updated_at: str updated_at: str
class Config: model_config = {"from_attributes": True}
from_attributes = True
class AssetUpdate(BaseModel): class AssetUpdate(BaseModel):
@@ -167,11 +167,24 @@ async def upload_asset_file(
if asset is None or asset.owner_id != current_user.id: if asset is None or asset.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Asset not found") raise HTTPException(status_code=404, detail="Asset not found")
# TODO: Implement actual file upload logic (S3, local filesystem, etc.) # Sanitize filename to prevent path traversal
file_path = f"uploads/{asset_id}_{file.filename}" safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else f"asset_{asset_id}"
file_path = f"uploads/{asset_id}_{safe_filename}"
# Validate file size against configured maximum
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
content = await file.read()
file_size = len(content)
if file_size > max_bytes:
raise HTTPException(
status_code=413,
detail=f"File size {file_size} exceeds maximum allowed size {max_bytes} bytes",
)
# TODO: Implement actual file write logic (S3, local filesystem, etc.)
asset.file_path = file_path asset.file_path = file_path
asset.mime_type = file.content_type asset.mime_type = file.content_type
asset.file_size = 0 # Will be updated after actual write asset.file_size = file_size
await db.commit() await db.commit()
await db.refresh(asset) await db.refresh(asset)
+9 -9
View File
@@ -3,6 +3,7 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from jose import jwt from jose import jwt
@@ -33,11 +34,6 @@ class RegisterRequest(BaseModel):
password: str password: str
class LoginRequest(BaseModel):
username: str
password: str
class UserResponse(BaseModel): class UserResponse(BaseModel):
id: int id: int
username: str username: str
@@ -104,12 +100,16 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)): async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
"""Authenticate user and return an access token.""" """Authenticate user and return an access token.
result = await db.execute(select(User).where(User.username == req.username))
Accepts form-encoded credentials (username/password) as expected by
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
"""
result = await db.execute(select(User).where(User.username == form.username))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user or not verify_password(req.password, user.hashed_password): if not user or not verify_password(form.password, user.hashed_password):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password", detail="Incorrect username or password",
+70 -62
View File
@@ -3,6 +3,7 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -16,92 +17,111 @@ router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)] DbDep = Annotated[AsyncSession, Depends(get_db)]
@router.get("/") # --- Pydantic schemas ---
class WorldCreate(BaseModel):
"""Schema for creating a world."""
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
visibility: WorldVisibility = WorldVisibility.PUBLIC
class WorldUpdate(BaseModel):
"""Schema for updating a world."""
name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = None
visibility: WorldVisibility | None = None
class WorldResponse(BaseModel):
"""Schema for world responses."""
id: int
name: str
description: str | None
visibility: WorldVisibility
owner_id: int
created_at: str | None = None
model_config = {"from_attributes": True}
# --- Endpoints ---
@router.get("/", response_model=list[WorldResponse])
async def list_worlds( async def list_worlds(
current_user: CurrentUser,
db: DbDep, db: DbDep,
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
visibility: WorldVisibility | None = None, visibility: WorldVisibility | None = None,
): ):
"""List worlds with optional visibility filter.""" """List worlds visible to the current user.
PUBLIC worlds are visible to everyone.
PRIVATE/FRIENDS_ONLY worlds are visible to their owner.
"""
query = select(World).options(selectinload(World.owner)) query = select(World).options(selectinload(World.owner))
# Filter by visibility if specified
if visibility: if visibility:
query = query.where(World.visibility == visibility) query = query.where(World.visibility == visibility)
else:
# Show PUBLIC worlds + owner's own worlds
query = query.where(
(World.visibility == WorldVisibility.PUBLIC)
| (World.owner_id == current_user.id)
)
query = query.offset(skip).limit(limit).order_by(World.created_at.desc()) query = query.offset(skip).limit(limit).order_by(World.created_at.desc())
result = await db.execute(query) result = await db.execute(query)
worlds = result.scalars().all() worlds = result.scalars().all()
return [ return worlds
{
"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}") @router.get("/{world_id}", response_model=WorldResponse)
async def get_world( async def get_world(
world_id: int, world_id: int,
current_user: CurrentUser,
db: DbDep, db: DbDep,
): ):
"""Get a single world by ID.""" """Get a single world by ID, respecting visibility."""
result = await db.execute( result = await db.execute(
select(World).where(World.id == world_id).options(selectinload(World.owner)) select(World).where(World.id == world_id).options(selectinload(World.owner))
) )
world = result.scalar_one_or_none() world = result.scalar_one_or_none()
if not world: if not world:
raise HTTPException(status_code=404, detail="World not found") raise HTTPException(status_code=404, detail="World not found")
return {
"id": world.id, # Allow access if world is PUBLIC or owned by current user
"name": world.name, if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id:
"description": world.description, raise HTTPException(status_code=404, detail="World not found")
"visibility": world.visibility.value if world.visibility else None,
"owner_id": world.owner_id, return world
"created_at": world.created_at.isoformat() if world.created_at else None,
}
@router.post("/") @router.post("/", response_model=WorldResponse, status_code=201)
async def create_world( async def create_world(
world_data: dict, world_data: WorldCreate,
current_user: CurrentUser, current_user: CurrentUser,
db: DbDep, db: DbDep,
): ):
"""Create a new world.""" """Create a new world."""
name = world_data.get("name")
if not name:
raise HTTPException(status_code=400, detail="World name is required")
world = World( world = World(
name=name, name=world_data.name,
description=world_data.get("description"), description=world_data.description,
owner_id=current_user.id, owner_id=current_user.id,
visibility=WorldVisibility( visibility=world_data.visibility,
world_data.get("visibility", "public")
),
) )
db.add(world) db.add(world)
await db.commit() await db.commit()
await db.refresh(world) await db.refresh(world)
return 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}") @router.put("/{world_id}", response_model=WorldResponse)
async def update_world( async def update_world(
world_id: int, world_id: int,
world_data: dict, world_data: WorldUpdate,
current_user: CurrentUser, current_user: CurrentUser,
db: DbDep, db: DbDep,
): ):
@@ -114,27 +134,16 @@ async def update_world(
if world.owner_id != current_user.id: if world.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not owner of this world") raise HTTPException(status_code=403, detail="Not owner of this world")
if world_data.get("name"): update_data = world_data.model_dump(exclude_unset=True)
world.name = world_data["name"] for field, value in update_data.items():
if world_data.get("description") is not None: setattr(world, field, value)
world.description = world_data["description"]
if world_data.get("visibility"):
world.visibility = WorldVisibility(world_data["visibility"])
await db.commit() await db.commit()
await db.refresh(world) await db.refresh(world)
return 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}") @router.delete("/{world_id}", status_code=204)
async def delete_world( async def delete_world(
world_id: int, world_id: int,
current_user: CurrentUser, current_user: CurrentUser,
@@ -151,4 +160,3 @@ async def delete_world(
await db.delete(world) await db.delete(world)
await db.commit() await db.commit()
return {"detail": "World deleted"}