From aea7bf5ada65fef998353917b331e61d5c1c8505 Mon Sep 17 00:00:00 2001 From: RamonCalvo Date: Sat, 28 Mar 2026 18:37:23 -0600 Subject: [PATCH] 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) --- .env.example | 4 ++ .gitignore | 5 +++ backend/.dockerignore | 3 ++ backend/Dockerfile | 6 +++ backend/app/__init__.py | 0 backend/app/config.py | 12 ++++++ backend/app/database.py | 13 +++++++ backend/app/main.py | 20 ++++++++++ backend/app/models.py | 21 +++++++++++ backend/app/routers/__init__.py | 0 backend/app/routers/bookmarks.py | 65 ++++++++++++++++++++++++++++++++ backend/app/routers/health.py | 8 ++++ backend/app/schemas.py | 25 ++++++++++++ backend/requirements.txt | 6 +++ docker-compose.yml | 33 ++++++++++++++++ 15 files changed, 221 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/bookmarks.py create mode 100644 backend/app/routers/health.py create mode 100644 backend/app/schemas.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..44f8b98 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +POSTGRES_USER=favs +POSTGRES_PASSWORD=favs +POSTGRES_DB=favs +ANTHROPIC_API_KEY=sk-ant-... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fabefc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.venv/ +.env +.DS_Store diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..8e2eca2 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +__pycache__ +*.pyc +.git diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c57f001 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..7907c5f --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..26f30f9 --- /dev/null +++ b/backend/app/database.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..e8012b9 --- /dev/null +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..cb134f0 --- /dev/null +++ b/backend/app/models.py @@ -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()) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/bookmarks.py b/backend/app/routers/bookmarks.py new file mode 100644 index 0000000..470221f --- /dev/null +++ b/backend/app/routers/bookmarks.py @@ -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() diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py new file mode 100644 index 0000000..93ced5e --- /dev/null +++ b/backend/app/routers/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter(tags=["health"]) + + +@router.get("/api/health") +async def health(): + return {"status": "ok"} diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..ebe8cc4 --- /dev/null +++ b/backend/app/schemas.py @@ -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} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..1c47ab1 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6e884a0 --- /dev/null +++ b/docker-compose.yml @@ -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: