GraphQL API where developers test their knowledge on Python, SQL, Docker and more, with XP, levels, streaks, badges, leaderboards and invite-code challenges.
FastAPI + Strawberry GraphQL + SQLModel over async PostgreSQL (Neon).
There are no REST endpoints except GET /health — everything goes through POST /graphql.
main.py # from app.main import app
migrate.py # one-off schema sync
app/
├── main.py # FastAPI app, lifespan, CORS, GraphQLRouter at /graphql
├── database.py # engine, get_session, build_async_url, sync_schema
├── core/
│ ├── config.py # pydantic-settings: DATABASE_URL, SECRET_KEY, CORS_ORIGINS
│ └── security.py # bcrypt hashing, JWT create/decode, token → user
├── models/ # SQLModel tables
│ ├── user.py topic.py question.py choice.py quiz.py
│ ├── stats.py # UserStats, Badge, UserBadge
│ ├── challenge.py # Challenge, ChallengeResult
│ └── timestamps.py # shared created_at / updated_at columns
├── services/
│ ├── stats_service.py # XP, levels, streaks — advances UserStats
│ ├── badge_service.py # badge catalogue, seeding, award rules
│ └── challenge_service.py # invite-code generation and lookup
└── graphql/
├── types.py # Strawberry types and inputs
├── queries.py # Query resolvers
├── mutations.py # Mutation resolvers
├── context.py # get_context, require_user, load_choices
└── schema.py # strawberry.Schema(query=Query, mutation=Mutation)
uv sync
cp .env.example .env # then fill in DATABASE_URL and SECRET_KEY
uv run fastapi devGraphiQL is served at http://127.0.0.1:8000/graphql.
DATABASE_URL takes the plain Neon string (postgresql://...?sslmode=require);
build_async_url swaps the scheme to postgresql+asyncpg and strips the libpq
query params, since asyncpg does not understand them. TLS is requested through
connect_args={"ssl": "require"} instead.
The browser blocks a cross-origin call unless this API names the frontend's
origin. Set it in .env, comma-separated:
CORS_ORIGINS=https://devquiz.vercel.app,https://www.devquiz.app,http://localhost:5173An origin is scheme + host + port, with no trailing slash and no path —
https://devquiz.vercel.app, not https://devquiz.vercel.app/.
Local dev needs no configuration: localhost:3000, localhost:5173,
localhost:8080 and the 127.0.0.1 equivalents are allowed by default. Add
your production domain once the frontend is deployed, and set the same variable
in the FastAPI Cloud environment.
Two things worth knowing:
- Preview deployments get a fresh subdomain per commit. List the ones you use,
or set
CORS_ALLOW_ALL=truein a throwaway environment. Do not ship that. allow_credentialsis on, and browsers reject a literal*in that mode, soCORS_ALLOW_ALLechoes the caller'sOriginback instead of sending a star.
There is no Alembic setup yet. On startup app.database.sync_schema() runs
SQLModel.metadata.create_all and then a short list of idempotent
ALTER TABLE ... ADD COLUMN IF NOT EXISTS statements.
That second step matters because create_all only creates missing tables —
it never adds a column to a table that already exists.
To sync without restarting the app:
uv run python migrate.pycreated_at and updated_at fill in automatically: the app sets them via
default_factory, the DB backs them with server_default=now(), and
updated_at carries onupdate=now() so any UPDATE refreshes it.
The badge catalogue is seeded on startup, also idempotently.
register / login return an AuthPayload with a JWT valid for 7 days:
Authorization: Bearer <access_token>
| Field | Auth | Notes |
|---|---|---|
topics / topic(id) |
— | |
questions(topicId, level) |
— | full questions, answer key included |
quiz(topicId!, level, limit = 10) |
— | N random questions, answers withheld |
leaderboard(limit = 10) |
— | global, by XP |
topicLeaderboard(topicId!, limit = 10) |
— | best percentage per user, then attempts |
publicProfile(username!) |
— | stats + badges |
challengeLeaderboard(code!) |
— | challenge info + ranked results |
me |
JWT | |
myHistory |
JWT | past attempts, newest first |
myStats |
JWT | XP, level, streak, totals |
myBadges |
JWT | newest first |
getHint(questionId!) |
JWT | falls back to "No hint available for this question" |
| Field | Auth | Notes |
|---|---|---|
register(email, username, password) |
— | password 8–72 bytes (bcrypt limit) |
login(email, password) |
— | |
createTopic(name, description) |
JWT | names are unique |
createQuestion(topicId, text, level, explanation, choices, hint) |
JWT | ≥2 choices, exactly one correct |
submitQuiz(topicId, answers) |
JWT | grades, stores the attempt, updates XP/streak/badges |
createChallenge(topicId, level, limit) |
JWT | returns an 8-char invite code |
joinChallenge(code) |
JWT | returns the challenge's questions — see note below |
submitChallenge(code, answers) |
JWT | grades, stores a ChallengeResult, updates XP/streak/badges |
The original spec typed it QuizSubmitResultType but trailed off mid-sentence
("User answers quiz... wait, this needs answers") — a result cannot be produced
before any answers exist. It is implemented as the draw step:
joinChallenge(code) -> [PublicQuestionType] # answers hidden
submitChallenge(code, answers) -> QuizSubmitResultType
Per finished quiz: 10 XP per correct answer, +50 for a perfect score, and a
streak bonus of +20 at 3 days or +50 at 7. The streak bonuses are tiers,
not cumulative — a 7-day streak pays 50, not 70.
The streak is advanced before XP is computed, so a quiz is paid the bonus for the streak it just extended.
Levels: Beginner 0–199, Junior 200–499, Mid 500–999, Senior 1000–1999,
Expert 2000+.
Streak: same day → unchanged, yesterday → +1, anything older or never → reset to 1.
Seeded at startup and awarded after every submitQuiz / submitChallenge:
First Quiz, Perfect Score, 10 Quizzes, 50 Quizzes, Streak 3, Streak 7,
Senior Dev, Expert Dev, Challenger, Challenge Win.
Thresholds use >= rather than ==, so a counter that jumps past the exact
value still earns the badge; a user never receives the same badge twice.
- The user table is named
users;useris a reserved word in PostgreSQL. questionsis public and does exposeisCorrectandhint. OnlyquizandjoinChallengehide the answer key. Putquestionsbehind auth if the client is untrusted.- Usernames are unique —
publicProfilelooks users up by username. - Grading validates the whole payload before writing, so an invalid answer never leaves a partial attempt behind. The attempt, the stats update and any new badges commit in a single transaction.
Challenge Wingoes to whoever holds the top percentage at submit time; it is not revoked if someone later beats them.- No avatars. Uploads need object storage (S3/MinIO) — a container filesystem is wiped on every deploy — so the feature is left out until that integration exists.
uv run fastapi deploySet DATABASE_URL, SECRET_KEY and CORS_ORIGINS in the FastAPI Cloud environment.