Last time we built a CLI todo app in 35 minutes. That was a local app. This time, we go bigger: a REST API that runs on the web.

The goal: build a bookmark manager API with CRUD operations, pagination, search, rate limiting, tests, and a Docker setup. All with Claude Code prompts. Deploy it and get a live URL.

Total time: 52 minutes from empty folder to deployed API.

What We’re Building

A bookmark manager REST API with these features:

  • Create, read, update, and delete bookmarks
  • Each bookmark has a URL, title, description, and tags
  • Pagination on the list endpoint
  • Full-text search by title and URL
  • Rate limiting (100 requests per minute)
  • Input validation with Pydantic
  • SQLite database with SQLModel
  • Automatic Swagger documentation
  • Docker support for deployment
  • Tests with pytest and httpx

Tech stack: Python, FastAPI, SQLModel (SQLite), Pydantic, uvicorn, Docker

Prerequisites

  • Claude Code installed and configured
  • Python 3.10+ installed
  • Docker installed (for deployment)

The Build Session

Total time: 52 minutes. 7 prompts. Here is everything.

Prompt 1: Full API Scaffold (Minute 0)

Create a FastAPI REST API for a bookmark manager. Endpoints:
- POST /bookmarks — create a bookmark
- GET /bookmarks — list all bookmarks
- GET /bookmarks/{id} — get a single bookmark
- PUT /bookmarks/{id} — update a bookmark
- DELETE /bookmarks/{id} — delete a bookmark

Each bookmark has: url (required), title (required), description (optional),
tags (list of strings, optional), created_at, updated_at.

Use SQLite with SQLModel for the database. Add input validation with Pydantic.
Use proper HTTP status codes. Return JSON responses.

Project structure: main.py, models.py, database.py, requirements.txt.

What Claude generated:

Four files, well-organized:

database.py:

from sqlmodel import SQLModel, create_engine, Session

DATABASE_URL = "sqlite:///./bookmarks.db"
engine = create_engine(DATABASE_URL, echo=False)

def create_db():
    SQLModel.metadata.create_all(engine)

def get_session():
    with Session(engine) as session:
        yield session

models.py:

from sqlmodel import SQLModel, Field
from datetime import datetime
from typing import Optional
import json

class BookmarkBase(SQLModel):
    url: str
    title: str
    description: Optional[str] = None
    tags: Optional[str] = None  # stored as JSON string

class Bookmark(BookmarkBase, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    created_at: datetime = Field(default_factory=datetime.utcnow)
    updated_at: datetime = Field(default_factory=datetime.utcnow)

class BookmarkCreate(BookmarkBase):
    pass

class BookmarkUpdate(SQLModel):
    url: Optional[str] = None
    title: Optional[str] = None
    description: Optional[str] = None
    tags: Optional[str] = None

class BookmarkRead(BookmarkBase):
    id: int
    created_at: datetime
    updated_at: datetime

main.py:

from fastapi import FastAPI, Depends, HTTPException
from sqlmodel import Session, select
from datetime import datetime
from database import create_db, get_session
from models import Bookmark, BookmarkCreate, BookmarkUpdate, BookmarkRead

app = FastAPI(title="Bookmark Manager API", version="1.0.0")

@app.on_event("startup")
def on_startup():
    create_db()

@app.post("/bookmarks", response_model=BookmarkRead, status_code=201)
def create_bookmark(bookmark: BookmarkCreate, session: Session = Depends(get_session)):
    db_bookmark = Bookmark.model_validate(bookmark)
    session.add(db_bookmark)
    session.commit()
    session.refresh(db_bookmark)
    return db_bookmark

@app.get("/bookmarks", response_model=list[BookmarkRead])
def list_bookmarks(session: Session = Depends(get_session)):
    bookmarks = session.exec(select(Bookmark)).all()
    return bookmarks

@app.get("/bookmarks/{bookmark_id}", response_model=BookmarkRead)
def get_bookmark(bookmark_id: int, session: Session = Depends(get_session)):
    bookmark = session.get(Bookmark, bookmark_id)
    if not bookmark:
        raise HTTPException(status_code=404, detail="Bookmark not found")
    return bookmark

@app.put("/bookmarks/{bookmark_id}", response_model=BookmarkRead)
def update_bookmark(
    bookmark_id: int,
    bookmark_update: BookmarkUpdate,
    session: Session = Depends(get_session)
):
    bookmark = session.get(Bookmark, bookmark_id)
    if not bookmark:
        raise HTTPException(status_code=404, detail="Bookmark not found")

    update_data = bookmark_update.model_dump(exclude_unset=True)
    for key, value in update_data.items():
        setattr(bookmark, key, value)
    bookmark.updated_at = datetime.utcnow()

    session.add(bookmark)
    session.commit()
    session.refresh(bookmark)
    return bookmark

@app.delete("/bookmarks/{bookmark_id}", status_code=204)
def delete_bookmark(bookmark_id: int, session: Session = Depends(get_session)):
    bookmark = session.get(Bookmark, bookmark_id)
    if not bookmark:
        raise HTTPException(status_code=404, detail="Bookmark not found")
    session.delete(bookmark)
    session.commit()

My review: Solid structure. Claude chose the right patterns — dependency injection for sessions, proper HTTP status codes (201 for create, 204 for delete, 404 for not found). The model separation (Base, Create, Update, Read) is standard FastAPI practice.

Issue spotted: Tags were stored as a JSON string, not a proper list. This means the API accepts "tags": "[\"python\", \"fastapi\"]" as a string, not "tags": ["python", "fastapi"] as a list. This is a common SQLite limitation with SQLModel, but the API should handle the conversion.

I tested it:

pip install fastapi sqlmodel uvicorn
uvicorn main:app --reload
curl -X POST http://localhost:8000/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"url": "https://fastapi.tiangolo.com", "title": "FastAPI Docs"}'

The API returned a 201 with the created bookmark. Swagger UI at http://localhost:8000/docs worked perfectly.

Time spent: 7 minutes

Prompt 2: Add Pagination (Minute 7)

Add pagination to GET /bookmarks. Accept query parameters: page (default 1)
and per_page (default 10, max 100). Return a JSON object with: items (list
of bookmarks), total (total count), page, per_page, and pages (total pages).

What Claude generated:

Claude updated the list endpoint and added a response model:

from math import ceil

class PaginatedBookmarks(SQLModel):
    items: list[BookmarkRead]
    total: int
    page: int
    per_page: int
    pages: int

@app.get("/bookmarks", response_model=PaginatedBookmarks)
def list_bookmarks(
    page: int = 1,
    per_page: int = 10,
    session: Session = Depends(get_session)
):
    if per_page > 100:
        per_page = 100
    if page < 1:
        page = 1

    total = session.exec(select(func.count(Bookmark.id))).one()
    offset = (page - 1) * per_page
    bookmarks = session.exec(
        select(Bookmark).offset(offset).limit(per_page)
    ).all()

    return PaginatedBookmarks(
        items=bookmarks,
        total=total,
        page=page,
        per_page=per_page,
        pages=ceil(total / per_page) if total > 0 else 0
    )

Clean implementation. The func.count import was needed from sqlmodel:

from sqlalchemy import func

Tested with:

curl "http://localhost:8000/bookmarks?page=1&per_page=5"

Worked perfectly. The pagination metadata was correct.

Time spent: 3 minutes

Prompt 3: Add Search (Minute 10)

Add a search endpoint: GET /bookmarks/search?q=keyword. Search in both title
and URL fields. Case insensitive. Return paginated results in the same format
as the list endpoint.

What Claude generated:

@app.get("/bookmarks/search", response_model=PaginatedBookmarks)
def search_bookmarks(
    q: str,
    page: int = 1,
    per_page: int = 10,
    session: Session = Depends(get_session)
):
    if per_page > 100:
        per_page = 100

    search_term = f"%{q}%"
    condition = or_(
        Bookmark.title.ilike(search_term),
        Bookmark.url.ilike(search_term)
    )

    total = session.exec(
        select(func.count(Bookmark.id)).where(condition)
    ).one()

    offset = (page - 1) * per_page
    bookmarks = session.exec(
        select(Bookmark).where(condition).offset(offset).limit(per_page)
    ).all()

    return PaginatedBookmarks(
        items=bookmarks,
        total=total,
        page=page,
        per_page=per_page,
        pages=ceil(total / per_page) if total > 0 else 0
    )

Claude used ilike for case-insensitive search and the or_ operator to search both fields. Both needed imports from sqlalchemy:

from sqlalchemy import func, or_

Time spent: 2 minutes

Prompt 4: Add Rate Limiting (Minute 12)

Add rate limiting: max 100 requests per minute per IP address. Use
slowapi library. Return 429 Too Many Requests when the limit is exceeded.
Add a custom error response with a message explaining the limit.

What Claude generated:

Claude added slowapi to requirements.txt and configured it:

from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

@app.post("/bookmarks", response_model=BookmarkRead, status_code=201)
@limiter.limit("100/minute")
def create_bookmark(request: Request, bookmark: BookmarkCreate, session: Session = Depends(get_session)):
    # ... same code but with request parameter added

Issue: Claude added the @limiter.limit decorator to every endpoint but forgot to add request: Request to every function signature. The create_bookmark function got it, but list_bookmarks and search_bookmarks did not. This caused a runtime error.

I caught this during testing and asked Claude to fix it:

The rate limiter crashes on GET /bookmarks because the function is missing
the request: Request parameter. Add it to all endpoints that have the
rate limiter decorator.

Fixed in 10 seconds. Claude added request: Request to all five endpoints.

Time spent: 4 minutes (including the fix)

Prompt 5: Fix Tags to Use Proper Lists (Minute 16)

Fix the tags field. The API should accept tags as a JSON list like
["python", "fastapi"] but SQLite cannot store lists natively. Store tags
as a comma-separated string in the database but convert to/from a list
in the API layer. The API should always return tags as a list, and accept
tags as a list.

What Claude generated:

Claude added a proper conversion layer:

class BookmarkCreate(SQLModel):
    url: str
    title: str
    description: Optional[str] = None
    tags: Optional[list[str]] = None

class BookmarkRead(SQLModel):
    id: int
    url: str
    title: str
    description: Optional[str] = None
    tags: Optional[list[str]] = None
    created_at: datetime
    updated_at: datetime

def tags_to_str(tags: Optional[list[str]]) -> Optional[str]:
    if tags is None:
        return None
    return ",".join(tags)

def str_to_tags(tags_str: Optional[str]) -> Optional[list[str]]:
    if tags_str is None or tags_str == "":
        return None
    return tags_str.split(",")

def bookmark_to_read(bookmark: Bookmark) -> BookmarkRead:
    return BookmarkRead(
        id=bookmark.id,
        url=bookmark.url,
        title=bookmark.title,
        description=bookmark.description,
        tags=str_to_tags(bookmark.tags),
        created_at=bookmark.created_at,
        updated_at=bookmark.updated_at
    )

Then each endpoint used bookmark_to_read() for the response and tags_to_str() when creating or updating. This is a clean approach for SQLite’s limitation.

Time spent: 4 minutes

Prompt 6: Write Tests (Minute 20)

Write API tests using httpx and pytest. Test all CRUD operations, pagination,
search, rate limiting, validation errors (missing required fields, invalid URL),
and edge cases (delete non-existent bookmark, update non-existent bookmark).
Use a separate test database.

What Claude generated:

A test_api.py file with 14 test functions:

import pytest
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, create_engine, Session
from main import app
from database import get_session

# Test database
TEST_DATABASE_URL = "sqlite:///./test_bookmarks.db"
test_engine = create_engine(TEST_DATABASE_URL)

def get_test_session():
    with Session(test_engine) as session:
        yield session

@pytest.fixture(autouse=True)
def setup_db():
    SQLModel.metadata.create_all(test_engine)
    app.dependency_overrides[get_session] = get_test_session
    yield
    SQLModel.metadata.drop_all(test_engine)
    app.dependency_overrides.clear()

client = TestClient(app)

def test_create_bookmark():
    response = client.post("/bookmarks", json={
        "url": "https://example.com",
        "title": "Example",
        "tags": ["test", "example"]
    })
    assert response.status_code == 201
    data = response.json()
    assert data["title"] == "Example"
    assert data["tags"] == ["test", "example"]
    assert "id" in data

def test_create_bookmark_missing_title():
    response = client.post("/bookmarks", json={
        "url": "https://example.com"
    })
    assert response.status_code == 422

def test_list_bookmarks_empty():
    response = client.get("/bookmarks")
    assert response.status_code == 200
    data = response.json()
    assert data["items"] == []
    assert data["total"] == 0

def test_list_bookmarks_pagination():
    for i in range(15):
        client.post("/bookmarks", json={
            "url": f"https://example{i}.com",
            "title": f"Example {i}"
        })
    response = client.get("/bookmarks?page=1&per_page=10")
    data = response.json()
    assert len(data["items"]) == 10
    assert data["total"] == 15
    assert data["pages"] == 2

def test_get_bookmark():
    create = client.post("/bookmarks", json={
        "url": "https://example.com",
        "title": "Example"
    })
    bookmark_id = create.json()["id"]
    response = client.get(f"/bookmarks/{bookmark_id}")
    assert response.status_code == 200
    assert response.json()["title"] == "Example"

def test_get_bookmark_not_found():
    response = client.get("/bookmarks/999")
    assert response.status_code == 404

def test_update_bookmark():
    create = client.post("/bookmarks", json={
        "url": "https://example.com",
        "title": "Example"
    })
    bookmark_id = create.json()["id"]
    response = client.put(f"/bookmarks/{bookmark_id}", json={
        "title": "Updated Example"
    })
    assert response.status_code == 200
    assert response.json()["title"] == "Updated Example"

def test_update_bookmark_not_found():
    response = client.put("/bookmarks/999", json={
        "title": "Does not exist"
    })
    assert response.status_code == 404

def test_delete_bookmark():
    create = client.post("/bookmarks", json={
        "url": "https://example.com",
        "title": "Example"
    })
    bookmark_id = create.json()["id"]
    response = client.delete(f"/bookmarks/{bookmark_id}")
    assert response.status_code == 204

def test_delete_bookmark_not_found():
    response = client.delete("/bookmarks/999")
    assert response.status_code == 404

def test_search_bookmarks():
    client.post("/bookmarks", json={
        "url": "https://python.org",
        "title": "Python Docs"
    })
    client.post("/bookmarks", json={
        "url": "https://go.dev",
        "title": "Go Docs"
    })
    response = client.get("/bookmarks/search?q=python")
    data = response.json()
    assert data["total"] == 1
    assert data["items"][0]["title"] == "Python Docs"

def test_search_case_insensitive():
    client.post("/bookmarks", json={
        "url": "https://FastAPI.com",
        "title": "FastAPI"
    })
    response = client.get("/bookmarks/search?q=fastapi")
    assert response.json()["total"] == 1

def test_pagination_limit():
    response = client.get("/bookmarks?per_page=200")
    # Should cap at 100, not error
    assert response.status_code == 200

I ran the tests:

pip install httpx pytest
pytest test_api.py -v

All 14 tests passed on the first run. Claude’s tests were thorough — they covered success cases, error cases, edge cases, and even the pagination cap.

Time spent: 5 minutes

Prompt 7: Docker Setup (Minute 25)

Create a Dockerfile and docker-compose.yml for this API. Use a multi-stage
build to keep the image small. The API should be accessible on port 8000.
Include a health check endpoint at GET /health.

What Claude generated:

Dockerfile:

FROM python:3.12-slim as builder

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.12-slim

WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .

EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

docker-compose.yml:

version: "3.8"

services:
  api:
    build: .
    ports:
      - "8000:8000"
    volumes:
      - bookmark-data:/app/data
    environment:
      - DATABASE_URL=sqlite:///./data/bookmarks.db
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

volumes:
  bookmark-data:

And the health endpoint in main.py:

@app.get("/health")
def health_check():
    return {"status": "healthy"}

Issue: The Dockerfile used curl in the healthcheck, but python:3.12-slim does not include curl. Claude should have used wget or a Python-based health check instead.

I fixed this by asking:

The healthcheck uses curl but python:3.12-slim does not have curl.
Use a python-based healthcheck command instead.

Claude changed the healthcheck to:

healthcheck:
  test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
  interval: 30s
  timeout: 10s
  retries: 3

I built and ran the Docker container:

docker compose up --build

The API was running at http://localhost:8000. Swagger docs at http://localhost:8000/docs. All endpoints working.

Time spent: 7 minutes (including the healthcheck fix)

The remaining time was spent deploying. I pushed the Docker image to Railway (a free hosting platform) and got a live URL in about 15 minutes. The deployment process itself is not Claude-specific — it is standard Docker deployment.

Total time: 52 minutes.

What Went Right

  • The initial scaffold was production-quality. Proper project structure, model separation, dependency injection, correct HTTP status codes. This was not throwaway code.
  • Swagger documentation was free. FastAPI generates interactive API docs automatically. Claude set this up correctly.
  • Tests were comprehensive. 14 tests covering success, error, and edge cases. All passed on the first try.
  • The iterative approach worked. Each prompt added a clean feature layer. The code stayed organized throughout.

What Went Wrong

1. Missing Request Parameter for Rate Limiter

Claude added the @limiter.limit decorator but forgot to add request: Request to 3 of 5 endpoints. This caused a runtime crash. The fix was trivial, but it shows that Claude sometimes only partially applies a change across multiple functions.

Lesson: When Claude modifies multiple similar functions, check all of them. AI sometimes fixes the first one correctly and forgets the rest.

2. Tags as a String

The initial implementation stored tags as a JSON string field. This is technically fine for SQLite, but the API sent and received raw strings instead of lists. The conversion layer fix took an extra prompt.

Lesson: Be specific about data formats in your first prompt. “Accept tags as a JSON array and store them as comma-separated in SQLite” would have avoided this.

3. Docker Healthcheck with Missing curl

Claude used curl in the Docker healthcheck, but the slim Python image does not include it. A small but common mistake.

Lesson: AI sometimes assumes tools are available that are not. Always check base image contents for Docker projects.

4. Deprecated Startup Event

Claude used @app.on_event("startup") which is deprecated in newer FastAPI versions. The modern approach is to use lifespan. It still works, but it shows Claude sometimes uses slightly outdated patterns.

Lesson: Check for deprecation warnings. Claude Code tends to use the most common patterns, which may not be the newest ones.

The Final Result

A complete bookmark manager API with:

  • 5 CRUD endpoints
  • Pagination with metadata
  • Full-text search
  • Rate limiting
  • Input validation
  • Health check endpoint
  • 14 passing tests
  • Docker deployment ready
  • Automatic Swagger documentation
# Run locally
pip install -r requirements.txt
uvicorn main:app --reload

# Run with Docker
docker compose up --build

# Test the API
curl -X POST http://localhost:8000/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"url": "https://kemalcodes.com", "title": "Kemal Codes", "tags": ["blog", "coding"]}'

curl http://localhost:8000/bookmarks?page=1&per_page=5
curl http://localhost:8000/bookmarks/search?q=kemal

Time and Cost

  • Total time: 52 minutes
  • Claude prompts: 7 (plus 2 fixes)
  • Estimated token usage: ~25,000 tokens
  • Hosting cost: $0 (Railway free tier or local Docker)
  • Dependencies: FastAPI, SQLModel, uvicorn, slowapi, httpx, pytest

Architecture Decisions Claude Made

One interesting aspect of vibe coding is watching the AI make architecture decisions. Here are the ones Claude made in this project and whether they were good:

1. SQLModel instead of raw SQLAlchemy — Good choice. SQLModel combines Pydantic validation with SQLAlchemy ORM in a single model definition. Less code, fewer files, and native FastAPI integration.

2. Separate model classes (Base, Create, Update, Read) — Good choice. This is standard FastAPI practice. The Create model does not include id or timestamps. The Update model makes all fields optional. The Read model includes everything.

3. SQLite instead of PostgreSQL — Fine for a demo. SQLite requires zero setup. For production, you would swap the connection string to PostgreSQL without changing any code.

4. Function-based endpoints instead of class-based — Fine for a small API. Class-based views (using FastAPI’s APIRouter with dependency injection) scale better for larger projects, but for 5 endpoints, functions are cleaner.

5. slowapi for rate limiting — Reasonable choice. It is the most popular FastAPI rate limiting library. For production, you might want Redis-backed rate limiting instead of in-memory.

I did not ask Claude to justify these decisions. It just made them. If you disagree with an architecture choice, you can ask Claude to change it: “Switch from SQLite to PostgreSQL with async SQLAlchemy.” Claude will refactor the code accordingly.

The point is: vibe coding is not about blindly accepting everything the AI generates. It is about reviewing the decisions and steering when needed.

What Would Production Look Like

The API we built is a solid prototype. For production, you would need:

  • PostgreSQL instead of SQLite (swap the DATABASE_URL)
  • Alembic for database migrations
  • Redis-backed rate limiting instead of in-memory
  • Authentication (API keys or OAuth2)
  • CORS configuration for frontend access
  • Logging with structured output
  • Environment-based configuration (dev, staging, prod)

Each of these is one Claude prompt. The base we built today is a foundation that scales. That is the power of starting with clean architecture — adding production features is incremental, not a rewrite.

Source Code

Full source code: kemalcodes/vibe-coding-projects (branch: rest-api-bookmarks)

What’s Next?

Next up: Build a Landing Page with Claude — HTML, CSS, and Deploy. We leave the backend world and see how well Claude handles frontend design. Can AI generate a good-looking, responsive landing page from a single prompt?