Nobody has started this yet — be first.
Business impact
PUT /bookmarks/{id} is a full replace -- every field is required, so a client that only wants to rename a bookmark (or just retag it) has to first GET the current record and resend everything back, or risk accidentally blanking fields it didn't mean to touch. This is extra round trips for every partial edit, and a real risk of accidental data loss if a client naively omits a field it didn't intend to change.
Problem
Only PUT (full replace, all fields required) exists. There is no way to update just the title, just the tags, or just the URL, independently.
Current behavior
PATCH /bookmarks/{id} returns 405 Method Not Allowed -- no PATCH route is registered at all.
Expected behavior
PATCH /bookmarks/{id} accepts a body with any subset of url, title, tags present, and updates only the fields that were actually supplied -- fields omitted from the request body are left unchanged. (A field explicitly present but empty, e.g. "title": "", should still be rejected the same way PUT/POST reject an empty title -- "present but invalid" is different from "not supplied at all".)
Steps to reproduce
ID=$(curl -s -X POST http://localhost:8081/bookmarks -H 'Content-Type: application/json'
-d '{"url":"https://example.com","title":"Old Title","tags":["go"]}' | jq -r .id)
curl -i -X PATCH http://localhost:8081/bookmarks/$ID -H 'Content-Type: application/json'
-d '{"title":"New Title"}'
Why this matters
This needs to distinguish "field not present in the JSON body" from "field present but zero-valued" -- a plain struct with string/[]string fields can't tell those apart (both look like the Go zero value). It also needs a genuinely dynamic UPDATE ... SET ... clause (only setting the columns that were actually supplied), built the same "clause list + args slice in lockstep" way Store.List already builds its WHERE clause -- the same core lesson, applied to SET instead of WHERE.
Suggested approach
Use pointer fields (or encoding/json's json.RawMessage/presence-checking pattern) in a new PatchRequest struct so "field absent" and "field present" are distinguishable; add a Store method that builds SET clauses only for the fields that were actually supplied, mirroring how Store.List builds WHERE clauses incrementally.
Acceptance criteria
Verification
go test ./practicetickets/... -run TestTicket10 -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
$git checkout -b feat/no-partial-update-patchFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/no-partial-update-patchSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign inmigrations/0001_init.sqlcurl http://localhost:8081/healthz
# {"status":"ok"}
To run without Docker, point the DB_* env vars from .env.example at a reachable Postgres and run go run ./cmd/server -- it applies the same migrations on startup.
The project's own test suite (go test ./...) is an integration suite against a real Postgres; it looks for one via BM_TEST_DB_HOST/BM_TEST_DB_PORT/BM_TEST_DB_USER/BM_TEST_DB_PASSWORD/BM_TEST_DB_NAME (defaults point at localhost:15433, database bookmarks_test) and skips cleanly rather than failing if that database is unreachable:
docker run -d --name bm_test_pg -e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=bookmarks_test -p 15433:5432 postgres:16-alpine
Work the tickets in PRACTICE_TICKETS.md (TICKET-01 through TICKET-10, all in one file, ordered easy -> hard); each names one Go test under practicetickets/:
go test ./practicetickets/... -run TestTicket01 -v # a single ticket
./practice_tickets_run.sh # all 10, clean pass/fail summary table
Level 2
Implement a feature
Extend the system within its own patterns.