feat(api): add public RESTful API with API key auth and documentation page

This commit is contained in:
2026-07-07 09:22:11 +08:00
parent 14c226d082
commit 6a49fedfbb
9 changed files with 910 additions and 5 deletions

407
app/routers/api.py Normal file
View File

@@ -0,0 +1,407 @@
from datetime import datetime, timedelta, timezone
from pathlib import Path
from secrets import token_hex
from fastapi import APIRouter, Depends, Header, HTTPException, Query, UploadFile, File
from pydantic import BaseModel
from sqlalchemy.orm import Session as DbSession
from app.database import get_db
from app.models import ApiKey, Profile, Postcard, User
from app.config import UPLOAD_DIR
router = APIRouter(prefix="/api/v1", tags=["api"])
# ─── Auth Dependency ──────────────────────────────────────────────
def get_api_user(
x_api_key: str = Header(..., alias="X-API-Key"),
db: DbSession = Depends(get_db),
) -> User:
"""Validate X-API-Key header and return the associated user."""
api_key = db.query(ApiKey).filter(ApiKey.key == x_api_key).first()
if not api_key or not api_key.is_active:
raise HTTPException(status_code=401, detail="Invalid or inactive API key")
# Update last_used_at
api_key.last_used_at = datetime.now(timezone.utc)
db.commit()
return api_key.user
def get_api_user_optional(
x_api_key: str | None = Header(None, alias="X-API-Key"),
db: DbSession = Depends(get_db),
) -> User | None:
"""Like get_api_user but returns None if no key provided (for public endpoints)."""
if not x_api_key:
return None
return get_api_user(x_api_key, db)
# ─── Pydantic Schemas ────────────────────────────────────────────
class ProfileCreate(BaseModel):
nickname: str
class ProfileUpdate(BaseModel):
nickname: str | None = None
class ProfileOut(BaseModel):
id: int
nickname: str
showcase_enabled: bool
showcase_visible: str
notes: str
created_at: datetime
model_config = {"from_attributes": True}
class PostcardCreate(BaseModel):
card_number: str
status: str
recipient_name: str | None = None
country: str | None = None
send_time: str | None = None
arrival_time: str | None = None
sender_name: str | None = None
receive_time: str | None = None
notes: str | None = None
class PostcardUpdate(BaseModel):
card_number: str | None = None
status: str | None = None
recipient_name: str | None = None
country: str | None = None
send_time: str | None = None
arrival_time: str | None = None
sender_name: str | None = None
receive_time: str | None = None
notes: str | None = None
class PostcardOut(BaseModel):
id: int
profile_id: int
card_number: str
status: str
recipient_name: str | None
country: str | None
send_time: datetime | None
arrival_time: datetime | None
sender_name: str | None
receive_time: datetime | None
notes: str
image_front: str | None
image_back: str | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ApiKeyCreate(BaseModel):
name: str = ""
class ApiKeyOut(BaseModel):
id: int
key: str
name: str
is_active: bool
created_at: datetime
last_used_at: datetime | None
model_config = {"from_attributes": True}
# ─── Helpers ──────────────────────────────────────────────────────
def _parse_date(s: str | None) -> datetime | None:
"""Parse a date or datetime string to datetime."""
if not s:
return None
s = s.strip()
for fmt in ("%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
raise HTTPException(status_code=400, detail=f"Invalid date format: {s}")
def _profile_or_404(user: User, profile_id: int, db: DbSession) -> Profile:
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
if not p:
raise HTTPException(status_code=404, detail="Profile not found")
return p
def _postcard_or_404(user: User, profile_id: int, postcard_id: int, db: DbSession) -> Postcard:
_profile_or_404(user, profile_id, db)
pc = db.query(Postcard).filter(
Postcard.id == postcard_id, Postcard.profile_id == profile_id
).first()
if not pc:
raise HTTPException(status_code=404, detail="Postcard not found")
return pc
# ═══════════════════════════════════════════════════════════════════
# Profiles
# ═══════════════════════════════════════════════════════════════════
@router.get("/profiles", response_model=list[ProfileOut])
def list_profiles(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
return db.query(Profile).filter(Profile.user_id == user.id).order_by(Profile.id).all()
@router.post("/profiles", response_model=ProfileOut, status_code=201)
def create_profile(body: ProfileCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
p = Profile(nickname=body.nickname.strip(), user_id=user.id)
db.add(p)
db.commit()
db.refresh(p)
return p
@router.get("/profiles/{profile_id}", response_model=ProfileOut)
def get_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
return _profile_or_404(user, profile_id, db)
@router.put("/profiles/{profile_id}", response_model=ProfileOut)
def update_profile(profile_id: int, body: ProfileUpdate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
p = _profile_or_404(user, profile_id, db)
if body.nickname is not None:
p.nickname = body.nickname.strip()
db.commit()
db.refresh(p)
return p
@router.delete("/profiles/{profile_id}")
def delete_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
p = _profile_or_404(user, profile_id, db)
db.delete(p)
db.commit()
return {"ok": True}
# ═══════════════════════════════════════════════════════════════════
# Postcards
# ═══════════════════════════════════════════════════════════════════
@router.get("/profiles/{profile_id}/postcards", response_model=list[PostcardOut])
def list_postcards(
profile_id: int,
status: str | None = Query(None),
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
):
_profile_or_404(user, profile_id, db)
q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
if status:
q = q.filter(Postcard.status == status)
return q.order_by(Postcard.id.desc()).all()
@router.post("/profiles/{profile_id}/postcards", response_model=PostcardOut, status_code=201)
def create_postcard(
profile_id: int,
body: PostcardCreate,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
):
_profile_or_404(user, profile_id, db)
cn = body.card_number.strip()
if db.query(Postcard).filter(Postcard.card_number == cn).first():
raise HTTPException(status_code=409, detail="Card number already exists")
pc = Postcard(
profile_id=profile_id,
card_number=cn,
status=body.status,
recipient_name=body.recipient_name,
country=body.country.strip().upper() if body.country else None,
send_time=_parse_date(body.send_time),
arrival_time=_parse_date(body.arrival_time),
sender_name=body.sender_name,
receive_time=_parse_date(body.receive_time),
notes=body.notes or "",
)
db.add(pc)
db.commit()
db.refresh(pc)
return pc
@router.get("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut)
def get_postcard(
profile_id: int,
postcard_id: int,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
):
return _postcard_or_404(user, profile_id, postcard_id, db)
@router.put("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut)
def update_postcard(
profile_id: int,
postcard_id: int,
body: PostcardUpdate,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
):
pc = _postcard_or_404(user, profile_id, postcard_id, db)
for field in ("status", "recipient_name", "sender_name", "notes"):
val = getattr(body, field)
if val is not None:
setattr(pc, field, val.strip() if isinstance(val, str) else val)
if body.card_number is not None:
cn = body.card_number.strip()
dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first()
if dup:
raise HTTPException(status_code=409, detail="Card number already exists")
pc.card_number = cn
if body.country is not None:
pc.country = body.country.strip().upper() if body.country else None
for field in ("send_time", "arrival_time", "receive_time"):
val = getattr(body, field)
if val is not None:
setattr(pc, field, _parse_date(val))
db.commit()
db.refresh(pc)
return pc
@router.delete("/profiles/{profile_id}/postcards/{postcard_id}")
def delete_postcard(
profile_id: int,
postcard_id: int,
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
):
pc = _postcard_or_404(user, profile_id, postcard_id, db)
db.delete(pc)
db.commit()
return {"ok": True}
# ═══════════════════════════════════════════════════════════════════
# Image Upload
# ═══════════════════════════════════════════════════════════════════
@router.post("/profiles/{profile_id}/postcards/{postcard_id}/upload", response_model=PostcardOut)
async def upload_image(
profile_id: int,
postcard_id: int,
side: str = Query("front", pattern="^(front|back)$"),
file: UploadFile = File(...),
user: User = Depends(get_api_user),
db: DbSession = Depends(get_db),
):
pc = _postcard_or_404(user, profile_id, postcard_id, db)
ext = Path(file.filename).suffix if file.filename else ".jpg"
save_name = f"pc_{pc.id}_{side}{ext}"
save_path = UPLOAD_DIR / save_name
content = await file.read()
save_path.write_bytes(content)
if side == "front":
pc.image_front = f"/uploads/{save_name}"
else:
pc.image_back = f"/uploads/{save_name}"
db.commit()
db.refresh(pc)
return pc
# ═══════════════════════════════════════════════════════════════════
# API Key Management
# ═══════════════════════════════════════════════════════════════════
@router.get("/keys", response_model=list[ApiKeyOut])
def list_api_keys(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
return db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
@router.post("/keys", response_model=ApiKeyOut, status_code=201)
def create_api_key(body: ApiKeyCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
key = f"mv_{token_hex(24)}"
ak = ApiKey(key=key, name=body.name.strip(), user_id=user.id)
db.add(ak)
db.commit()
db.refresh(ak)
return ak
@router.delete("/keys/{key_id}")
def delete_api_key(key_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
if not ak:
raise HTTPException(status_code=404, detail="API key not found")
db.delete(ak)
db.commit()
return {"ok": True}
# ═══════════════════════════════════════════════════════════════════
# Showcase (public, no auth required)
# ═══════════════════════════════════════════════════════════════════
class ShowcaseProfileOut(BaseModel):
id: int
nickname: str
showcase_visible: str
sent: list[PostcardOut]
received: list[PostcardOut]
model_config = {"from_attributes": True}
class ShowcaseUserOut(BaseModel):
username: str
showcase_mode: str
profiles: list[ShowcaseProfileOut]
@router.get("/showcase/{username}", response_model=ShowcaseUserOut)
def showcase_user(username: str, db: DbSession = Depends(get_db)):
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
profiles = db.query(Profile).filter(Profile.user_id == user.id, Profile.showcase_enabled == True).all()
result_profiles = []
for p in profiles:
cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all()
if p.showcase_visible in ("sent", "both"):
sent = [c for c in cards if c.status in ("sent", "delivered")]
else:
sent = []
if p.showcase_visible in ("received", "both"):
received = [c for c in cards if c.status == "received"]
else:
received = []
if sent or received:
sent.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
received.sort(key=lambda c: c.receive_time or c.created_at, reverse=True)
result_profiles.append(ShowcaseProfileOut(
id=p.id, nickname=p.nickname, showcase_visible=p.showcase_visible,
sent=sent, received=received,
))
return ShowcaseUserOut(username=user.username, showcase_mode=user.showcase_mode, profiles=result_profiles)
@router.get("/showcase/{username}/profile/{profile_id}", response_model=ShowcaseProfileOut)
def showcase_profile(username: str, profile_id: int, db: DbSession = Depends(get_db)):
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id, Profile.showcase_enabled == True).first()
if not p:
raise HTTPException(status_code=404, detail="Profile not found or showcase disabled")
cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all()
sent = [c for c in cards if c.status in ("sent", "delivered")] if p.showcase_visible in ("sent", "both") else []
received = [c for c in cards if c.status == "received"] if p.showcase_visible in ("received", "both") else []
sent.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
received.sort(key=lambda c: c.receive_time or c.created_at, reverse=True)
return ShowcaseProfileOut(id=p.id, nickname=p.nickname, showcase_visible=p.showcase_visible, sent=sent, received=received)