A production-structured FastAPI backend built for learning how real backend systems are designed.
Week 25 of the AI & Full Stack Engineering Program.
PromptForge is a backend API that lets teams store, version, and search AI prompts — the same kind of system that companies like OpenAI, Anthropic, or any AI startup would build internally.
But more importantly, it's a learning project designed to teach you how production backends actually work — not just how to write endpoints, but how to think about authentication, data isolation, search, and system design.
| Concept | What You'll Build |
|---|---|
| JWT Authentication | Register/login system with signed tokens |
| Multi-Tenancy | Data isolation so users never see each other's data |
| Role-Based Access Control | Admins, Developers, Reviewers with different permissions |
| Database Design | Relational models with versioning built-in |
| Vector Search | Semantic prompt search using Qdrant |
| Modular Architecture | Separation of routers, services, schemas, and models |
Imagine you're building an AI product. Your team writes hundreds of prompts — for summarization, classification, code generation, etc. You need to:
- Track who wrote which prompt
- Keep a version history when prompts change
- Make sure Company A's prompts are never visible to Company B (multi-tenancy)
- Let users search prompts semantically — "find me prompts similar to this one"
That's exactly what PromptForge does.
Request comes in
│
▼
Router ← handles HTTP (what URL, what method, what status code)
│
▼
Service ← handles business logic (what actually happens)
│
┌─┴─────────────┐
▼ ▼
Database Qdrant
(PostgreSQL) (Vector DB)
Every layer has a single job. Routers don't touch the database directly. Services don't know about HTTP. This is called separation of concerns — it makes code easier to test, change, and understand.
PromptForge/
├── .env.example ← Config template — copy to .env
├── requirements.txt ← Python dependencies
└── app/
├── main.py ← App entry point, router registration
│
├── core/
│ ├── config.py ← All settings (SECRET_KEY, DB URL, Qdrant host)
│ └── security.py ← JWT creation, password hashing, RBAC dependency
│
├── db/
│ ├── database.py ← DB engine, session dependency, table creation
│ └── models.py ← User, Prompt, PromptVersion SQLModel tables
│
├── schemas/
│ ├── auth_schema.py ← Request/response shapes for auth routes
│ ├── user_schema.py ← UserResponse (no password exposed)
│ └── prompt_schema.py ← Prompt create, list, version, search shapes
│
├── services/
│ ├── auth_service.py ← register_user, login_user logic
│ ├── prompt_service.py ← create_prompt, list, versioning logic
│ └── vector_service.py ← Qdrant client, embedding, search
│
└── routers/
├── auth_router.py ← POST /auth/register, POST /auth/login
├── user_router.py ← GET /users/me, /admin-only
└── prompt_router.py ← POST /prompts/create, GET /list, POST /search
| Tool | Purpose |
|---|---|
| FastAPI | Web framework — fast, async, auto-generates docs |
| SQLModel | Database ORM — combines SQLAlchemy + Pydantic |
| SQLite / PostgreSQL | Relational database for users, prompts, versions |
| Qdrant | Vector database for semantic search |
| python-jose | JWT creation and verification |
| passlib / bcrypt | Password hashing |
| pydantic-settings | Environment variable management |
| Method | URL | Who Can Use | What It Does |
|---|---|---|---|
| POST | /auth/register |
Anyone | Create account, get tenant_id assigned |
| POST | /auth/login |
Registered users | Get JWT token |
| Method | URL | Who Can Use | What It Does |
|---|---|---|---|
| GET | /users/me |
Any logged-in user | See your own identity from JWT |
| GET | /users/admin-only |
Admin only | Example RBAC-protected route |
| GET | /users/reviewer-or-admin |
Admin + Reviewer | Example multi-role route |
| Method | URL | Who Can Use | What It Does |
|---|---|---|---|
| POST | /prompts/create |
Admin, Developer | Create prompt → saves to DB + Qdrant |
| GET | /prompts/list |
Any logged-in user | List prompts (tenant-scoped) |
| GET | /prompts/{id}/versions |
Any logged-in user | View version history of a prompt |
| POST | /prompts/search |
Any logged-in user | Semantic search via Qdrant |
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐
│ User │ │ Prompt │ │ PromptVersion │
├──────────────────┤ ├──────────────────┤ ├──────────────────────┤
│ id (PK) │──────▶│ id (PK) │──────▶│ id (PK) │
│ username │ │ content │ │ prompt_id (FK) │
│ hashed_password │ │ user_id (FK) │ │ version_number │
│ role │ │ tenant_id ◀────┐ │ │ content │
│ tenant_id ───────────────│ tenant_id │ │ │ created_at │
└──────────────────┘ │ created_at │ └──────────────────────────────┘
└────────────────┘
tenant_id is copied from User onto Prompt so filtering by tenant never requires a JOIN.
After login, the server gives you a signed token containing:
{
"user_id": 1,
"tenant_id": "uuid-here",
"role": "developer",
"exp": 1234567890
}Every protected route reads this token — no database lookup needed on each request.
Every query that returns data is filtered by tenant_id:
select(Prompt).where(Prompt.tenant_id == current_user["tenant_id"])This is the single most important rule in a multi-tenant system. Without it, User A could read User B's data.
admin → can do everything
developer → can create and view prompts
reviewer → can only view and approve
Enforced via a dependency factory — require_role(["admin", "developer"]) — which FastAPI injects into routes automatically.
Every prompt starts at version_number = 1. When a prompt is edited, a new PromptVersion row is created. The original is never deleted — you always have the full history.
When a prompt is saved, it's also converted to a vector embedding (a list of 384 numbers that represent its meaning) and stored in Qdrant. When you search, your query is also embedded and Qdrant finds the most similar vectors.
Note: This project uses a mock embedding for learning. To make search semantically accurate, swap
create_embedding()invector_service.pywith OpenAI or sentence-transformers.
pip install -r requirements.txt# Requires Docker
docker run -p 6333:6333 qdrant/qdrantcp .env.example .env
# Edit .env and set your SECRET_KEYuvicorn app.main:app --reloadhttp://localhost:8000/docs
FastAPI auto-generates an interactive UI where you can test every endpoint.
# 1. Register a developer
POST /auth/register
{ "username": "alice", "password": "pass123", "role": "developer" }
# 2. Login and copy the token
POST /auth/login
{ "username": "alice", "password": "pass123" }
→ returns: { "access_token": "eyJ..." }
# 3. Set Authorization: Bearer eyJ... in all further requests
# 4. Create a prompt
POST /prompts/create
{ "content": "Explain transformer architecture to a 10 year old" }
# 5. List your prompts
GET /prompts/list
# 6. Search semantically
POST /prompts/search
{ "query": "explain AI concepts simply" }
# 7. View version history
GET /prompts/1/versionsIn app/services/vector_service.py, replace create_embedding() with:
Option A — OpenAI:
import openai
def create_embedding(text: str) -> list:
response = openai.embeddings.create(model="text-embedding-3-small", input=text)
return response.data[0].embeddingOption B — Free & Local (sentence-transformers):
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2") # 384-dim, matches EMBEDDING_DIM
def create_embedding(text: str) -> list:
return model.encode(text).tolist()In .env, change:
DATABASE_URL=postgresql://username:password@localhost:5432/promptforge
Install the driver:
pip install psycopg2-binaryEverything else stays the same — SQLModel handles the rest.
- Add
PUT /prompts/{id}to update a prompt and auto-increment version - Add email field to User and send a welcome email on registration
- Add rate limiting per tenant
- Add pagination to
GET /prompts/list - Replace mock embeddings with a real model
- Add a
GET /prompts/search/historyto track past searches