Prompt file imported from tarikstupac/cizeblize-be (
.github/prompts/task-07-api-handlers.prompt.md). Copyright stays with the author.
Task 07 — API Handlers & Router
Read
.github/agents.mdbefore starting. Depends on: all prior tasks (01–06).
Goal
Implement all HTTP handlers and wire them into a router using the Go standard
library only (net/http). No frameworks. No gorilla/mux.
Files to Create
internal/api/routes.go
internal/api/auth_handler.go
internal/api/cards_handler.go
internal/api/matches_handler.go
Shared Helpers (put in internal/api/routes.go or a small internal/api/helpers.go)
userIDFromContext(ctx context.Context) (int32, bool)
Type-assert auth.UserIDKey from context; return 0, false if missing.
Response helpers
Use existing helpers for json responses in internal/json/json.go
internal/api/routes.go
Deps struct
type Deps struct {
DB *database.Queries
RawDB *sql.DB // needed for transaction support if required
Index *index.CityIndex
Matcher *matcher.Matcher
Cache *cache.Cache
}
NewRouter(d Deps) http.Handler
Use http.NewServeMux(). Register routes:
| Method + Path | Handler | Auth required |
|---|---|---|
POST /auth/register |
registerHandler(d) |
No |
POST /auth/login |
loginHandler(d) |
No |
POST /auth/logout |
logoutHandler(d) |
No |
POST /users/{id}/duplicates |
updateDuplicatesHandler(d) |
Yes |
POST /users/{id}/wanted |
updateWantedHandler(d) |
Yes |
GET /matches |
matchesHandler(d) |
Yes |
Apply auth.Middleware(d.DB, d.Cache) only to the three protected routes.
Go 1.22+ ServeMux supports {id} path parameters natively — use that syntax.
internal/api/auth_handler.go
POST /auth/register
Request body (JSON):
{ "username": "alice", "password": "secret", "city_id": 3, "contact_info": "..." }
- Decode body; validate:
usernamenon-empty,passwordnon-empty,city_id > 0,contact_infonon-empty. Return 400 on validation failure. - Hash password:
bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost). - Call
d.DB.CreateUser(ctx, ...). If the DB returns a unique-constraint violation (duplicate username), return 409 with{"error":"username already taken"}. Detect viapq.Errorwith code"23505". - Call
auth.CreateSession(ctx, d.DB, d.Cache, user.ID). - Call
auth.SetSessionCookie(w, token). - Return 201 with the created user's public fields (id, username, city_id, contact_info, created_at).
POST /auth/login
Request body:
{ "username": "alice", "password": "secret" }
- Decode body; validate non-empty fields. Return 400 on failure.
- Call
d.DB.GetUserByUsername(ctx, username). Onsql.ErrNoRows, return 401{"error":"invalid credentials"}. bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)). On mismatch, return 401.auth.CreateSession,auth.SetSessionCookie, return 200 with public user fields.
POST /auth/logout
- Read
session_idcookie. If missing, return 200 (idempotent). - Call
auth.DeleteSession(ctx, d.DB, d.Cache, token). - Call
auth.ClearSessionCookie(w). - Return 200
{"ok":true}.
internal/api/cards_handler.go
Both handlers share the same write-through pattern:
parse & validate body
→ verify session user == path {id}
→ write to DB (UpdateUserDuplicates / UpdateUserWanted)
→ rebuild bitset
→ call index.UpdateUser
→ call matcher.InvalidateUser
→ return 200
POST /users/{id}/duplicates
Request body:
{ "cards": { "42": 3, "980": 1 } }
- Parse
{id}from the URL path usingr.PathValue("id"). Parse asint32. Return 400 on parse failure. - Retrieve
userIDfrom context (userIDFromContext). IfuserID != pathID, return 403. - Decode body.
cardsismap[string]int. Return 400 if body is malformed. - Validate: all map keys must be parseable as integers in
[1, model.MaxCardID]; values must be > 0. Return 400 if any key/value is invalid. - Call
d.DB.UpdateUserDuplicates(ctx, database.UpdateUserDuplicatesParams{ID: pathID, DuplicatesWithCounts: marshaledJSON}). TheDuplicatesWithCountsfield isjson.RawMessage— pass the raw re-serialized JSON. - Extract card-number keys from the input map; convert to
[]int; callmodel.CardsToBitset. - Retrieve the user from
d.Index.GetUser(uint32(pathID)). If not found, return 404. - Update the user's
Duplicatesfield and calld.Index.UpdateUser(user). - Call
d.Matcher.InvalidateUser(uint32(pathID)). - Return 200
{"ok":true}.
POST /users/{id}/wanted
Request body:
{ "cards": [1, 42, 980] }
Same pattern as duplicates handler, but:
- Body decodes to
struct{ Cards []int }. - Validate: each element in
[1, model.MaxCardID]. Duplicates in the list are allowed (idempotent). - DB call:
d.DB.UpdateUserWanted(ctx, database.UpdateUserWantedParams{ID: pathID, Wanted: cards}).Wantedis[]int32in the generated code — convert accordingly. - Update
user.Wantedbitset and calld.Index.UpdateUser. d.Matcher.InvalidateUser.- Return 200
{"ok":true}.
internal/api/matches_handler.go
GET /matches?limit=N
- Retrieve
userIDfrom context. - Parse
limitquery param; default 50; cap at 50 (any value > 50 is silently clamped to 50). - Call
d.Matcher.FindMatches(uint32(userID), limit). - Return 200 with JSON array of matches.
Response shape per match:
{ "user_id": 7, "give_score": 3, "get_score": 2, "score": 2 }
Error handling conventions
- 400 Bad Request — malformed JSON, missing required fields, invalid values.
- 401 Unauthorized — missing/invalid/expired session (handled by middleware; handlers need not re-check).
- 403 Forbidden — authenticated user does not own the resource.
- 404 Not Found — resource does not exist.
- 409 Conflict — unique constraint violation (e.g., username taken).
- 500 Internal Server Error — unexpected DB or internal errors; log the error server-side, return generic
{"error":"internal server error"}to client.
Acceptance Criteria
-
go build ./internal/api/...exits 0. -
go test -race ./internal/api/...passes (write at minimum a smoke test thatNewRouterreturns a non-nil handler and that unauthenticated requests to protected routes receive 401). - No external imports beyond
golang.org/x/crypto/bcrypt,github.com/lib/pq, standard library, and internal packages.