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:
RamonCalvo 2026-03-28 18:37:23 -06:00
commit aea7bf5ada
15 changed files with 221 additions and 0 deletions

4
.env.example Normal file
View file

@ -0,0 +1,4 @@
POSTGRES_USER=favs
POSTGRES_PASSWORD=favs
POSTGRES_DB=favs
ANTHROPIC_API_KEY=sk-ant-...

5
.gitignore vendored Normal file
View file

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

3
backend/.dockerignore Normal file
View file

@ -0,0 +1,3 @@
__pycache__
*.pyc
.git

6
backend/Dockerfile Normal file
View 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
View file

12
backend/app/config.py Normal file
View 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
View 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
View 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
View 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())

View file

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

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