feat: add bookmarks CRUD API with FastAPI and Docker Compose
REST API for managing personal bookmarks (title, link, category). PostgreSQL database with async SQLAlchemy, containerized with Docker Compose. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
aea7bf5ada
15 changed files with 221 additions and 0 deletions
4
.env.example
Normal file
4
.env.example
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
POSTGRES_USER=favs
|
||||
POSTGRES_PASSWORD=favs
|
||||
POSTGRES_DB=favs
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.env
|
||||
.DS_Store
|
||||
3
backend/.dockerignore
Normal file
3
backend/.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
6
backend/Dockerfile
Normal file
6
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
12
backend/app/config.py
Normal file
12
backend/app/config.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "postgresql+asyncpg://favs:favs@favs-db:5432/favs"
|
||||
anthropic_api_key: str = ""
|
||||
categorize_model: str = "claude-haiku-4-5-20251001"
|
||||
|
||||
model_config = {"env_file": ".env"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
13
backend/app/database.py
Normal file
13
backend/app/database.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url)
|
||||
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession]:
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
20
backend/app/main.py
Normal file
20
backend/app/main.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.database import engine
|
||||
from app.models import Base
|
||||
from app.routers import bookmarks, categorize, health
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Favs API", lifespan=lifespan)
|
||||
app.include_router(health.router)
|
||||
app.include_router(bookmarks.router)
|
||||
app.include_router(categorize.router)
|
||||
21
backend/app/models.py
Normal file
21
backend/app/models.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Bookmark(Base):
|
||||
__tablename__ = "bookmarks"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
link: Mapped[str] = mapped_column(String(2000), nullable=False)
|
||||
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
65
backend/app/routers/bookmarks.py
Normal file
65
backend/app/routers/bookmarks.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Bookmark
|
||||
from app.schemas import BookmarkCreate, BookmarkResponse, BookmarkUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/bookmarks", tags=["bookmarks"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[BookmarkResponse])
|
||||
async def list_bookmarks(
|
||||
category: str | None = None, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
query = select(Bookmark).order_by(Bookmark.created_at.desc())
|
||||
if category:
|
||||
query = query.where(Bookmark.category == category)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{bookmark_id}", response_model=BookmarkResponse)
|
||||
async def get_bookmark(bookmark_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
|
||||
bookmark = result.scalar_one_or_none()
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.post("/", response_model=BookmarkResponse, status_code=201)
|
||||
async def create_bookmark(data: BookmarkCreate, db: AsyncSession = Depends(get_db)):
|
||||
bookmark = Bookmark(**data.model_dump())
|
||||
db.add(bookmark)
|
||||
await db.commit()
|
||||
await db.refresh(bookmark)
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.put("/{bookmark_id}", response_model=BookmarkResponse)
|
||||
async def update_bookmark(
|
||||
bookmark_id: uuid.UUID, data: BookmarkUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
|
||||
bookmark = result.scalar_one_or_none()
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(bookmark, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(bookmark)
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.delete("/{bookmark_id}", status_code=204)
|
||||
async def delete_bookmark(bookmark_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Bookmark).where(Bookmark.id == bookmark_id))
|
||||
bookmark = result.scalar_one_or_none()
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
await db.delete(bookmark)
|
||||
await db.commit()
|
||||
8
backend/app/routers/health.py
Normal file
8
backend/app/routers/health.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
25
backend/app/schemas.py
Normal file
25
backend/app/schemas.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BookmarkCreate(BaseModel):
|
||||
title: str
|
||||
link: str
|
||||
|
||||
|
||||
class BookmarkUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
link: str | None = None
|
||||
category: str | None = None
|
||||
|
||||
|
||||
class BookmarkResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
link: str
|
||||
category: str | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
pydantic-settings==2.7.1
|
||||
anthropic==0.43.0
|
||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
services:
|
||||
favs-db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: favs
|
||||
POSTGRES_PASSWORD: favs
|
||||
POSTGRES_DB: favs
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- favs_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U favs"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
favs-api:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://favs:favs@favs-db:5432/favs
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||
depends_on:
|
||||
favs-db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
volumes:
|
||||
favs_pgdata:
|
||||
Loading…
Reference in a new issue