A user made someone else the owner of a note by adding one field to the JSON
See the problem
Section titled “See the problem”The Notes API takes a PATCH with JSON and saves it on the note:
note = Note.objects.get(pk=pk, owner=request.user)for key, value in json.loads(request.body).items(): setattr(note, key, value)note.save()Ana sends {"title": "Mine now", "owner_id": 2} for her own note. The note moves to Ben’s list. With {"id": 1} she could overwrite note 1. A blank title of spaces is saved too. The client chose which fields to change, and the server never said which ones it may.
Define the task
Section titled “Define the task”Write update_note(note, data): apply a partial update, as a DRF serializer does for PATCH.
- Input: the note as a dict (
id,owner,title,body), and the JSON the client sent. - Output:
(note_after, errors). Onlytitleandbodycan change; anything else in the JSON is ignored. - The rules: a title is trimmed, not blank, at most 200 characters. A body must be text. Fields not sent stay as they are.
- The hard part: if anything is invalid, nothing changes. And the note passed in is never changed in place.
Example: update_note(NOTE, {"owner": 2, "title": "Mine now"}) gives ({"id": 7, "owner": 1, "title": "Mine now", "body": ""}, {}).
Pause and think
Section titled “Pause and think”Hints open one at a time:
Hint 1 of 3
Not the client’s. Anyone can add a key to JSON.
Hint 2 of 3
The server needs a list of writable fields, and must ignore the rest.
Hint 3 of 3
Check every writable field first, and save only if all are fine. That is what a serializer’s is_valid() and save() split does.
Understand the idea
Section titled “Understand the idea”DRF (Django REST Framework) builds JSON APIs on Django. The DRF version of the Notes API is in examples/django/notes/api_v2.py, served at /api/v2/notes/. Compare it with notes/views.py, which does each step by hand.
A serializer turns a model into JSON, and JSON into a checked model. A ModelSerializer reads the field types and rules from the model:
class NoteSerializer(serializers.ModelSerializer): class Meta: model = Note fields = ("id", "title", "body", "created_at", "updated_at") read_only_fields = ("id", "created_at", "updated_at")Only fields in fields, minus read_only_fields, can be set. owner isn’t listed, so a client can’t send it. This stops mass assignment: saving fields the client should not control. serializer.is_valid() checks every field and fills serializer.errors; serializer.save() writes. A validate_title method adds your own rule.
A viewset is the five actions in one class. ModelViewSet gives list, create, retrieve, update, partial update and destroy. You set two things that matter:
get_queryset():Note.objects.filter(owner=self.request.user), so other users’ notes are 404.perform_create():serializer.save(owner=self.request.user), so the owner comes from the session.
A router writes the URLs. router.register("notes", NoteViewSet) gives /notes/ and /notes/<pk>/ with the right methods.
Permissions, throttling and pagination are classes, set per view or for the whole API in REST_FRAMEWORK in settings:
- Permissions answer “may this user do this?”:
IsAuthenticated,IsAdminUser, or your ownhas_object_permission. The first practice problem writes one. - Throttling answers “too many requests?” with 429 and a
Retry-Afterheader.UserRateThrottlewith"1000/hour". The second practice problem builds one. - Pagination answers “which slice?”:
PageNumberPaginationaddscount,nextandprevious. The third practice problem builds the links.
PATCH /api/v2/notes/7/ in DRF
Parts:
- Client (the request is here)
- Auth, permissions, throttle
- NoteViewSet.partial_update
- NoteSerializer
- Database
Arrows:
- Client sends requests to Auth, permissions, throttle
- Auth, permissions, throttle sends requests to NoteViewSet.partial_update
- NoteViewSet.partial_update sends requests to NoteSerializer
- NoteSerializer sends requests to Database
Step 1 of 6. Ben sends PATCH /api/v2/notes/7/ with {"title": "Mine now", "owner": 2}. Note 7 is Ana’s.
Climb the ladder
Section titled “Climb the ladder”Three versions of update_note. The tests slip in owner and id, send a blank title, and check the original isn’t touched.
Save every key
The idea: note.update(data), and return it.
A title change works.
def update_note(note: dict, data: dict) -> tuple[dict, dict[str, list[str]]]: note.update(data) # every key the client sends is saved return note, {}- Client sets owner
Yes- Bad values
Saved- Why
- The client decides which fields change.
Where it breaks: {"owner": 2} gives the note away, and {"id": 1} points it at another row. This is mass assignment.
Only writable fields
The idea: Copy just title and body from the data, if sent.
The waste in Okay: it let the client pick the fields.
Highlighted lines are new in this rung.
WRITABLE = ("title", "body")
def update_note(note: dict, data: dict) -> tuple[dict, dict[str, list[str]]]: for field in WRITABLE: if field in data: note[field] = data[field] # only fields clients may set, but no checks return note, {}- Client sets owner
Nowas Yes- Bad values
Saved- Why
- The server now owns the list of fields that can change.
What changed from the previous rung
WRITABLE = ("title", "body")
def update_note(note: dict, data: dict) -> tuple[dict, dict[str, list[str]]]: note.update(data) # every key the client sends is saved for field in WRITABLE: if field in data: note[field] = data[field] # only fields clients may set, but no checks return note, {}Where it breaks: a blank title, or a body that is a number, is saved. And it changes the note it was given, so a failed save leaves it half changed.
Check every field, then build a new note
The idea: Collect changes and errors for the writable fields; if no errors, return a new dict with the changes, else the note as it was.
The waste in Better: it trusted the values of the fields it allowed.
Highlighted lines are new in this rung.
WRITABLE = ("title", "body")
def update_note(note: dict, data: dict) -> tuple[dict, dict[str, list[str]]]: errors: dict[str, list[str]] = {} changes: dict = {} if "title" in data: title = data["title"].strip() if isinstance(data["title"], str) else "" if not title: errors["title"] = ["This field may not be blank."] elif len(title) > 200: errors["title"] = ["Ensure this field has no more than 200 characters."] changes["title"] = title if "body" in data: if isinstance(data["body"], str): changes["body"] = data["body"] else: errors["body"] = ["Not a valid string."] if errors: return note, errors # nothing changes unless everything is valid return {**note, **changes}, {} # read-only fields like id and owner are ignored- Client sets owner
No- Bad values
Refused, with errorswas Saved- Why
- All or nothing, like is_valid() before save().
What changed from the previous rung
WRITABLE = ("title", "body")
def update_note(note: dict, data: dict) -> tuple[dict, dict[str, list[str]]]: for field in WRITABLE: if field in data: note[field] = data[field] # only fields clients may set, but no checks return note, {} errors: dict[str, list[str]] = {} changes: dict = {} if "title" in data: title = data["title"].strip() if isinstance(data["title"], str) else "" if not title: errors["title"] = ["This field may not be blank."] elif len(title) > 200: errors["title"] = ["Ensure this field has no more than 200 characters."] changes["title"] = title if "body" in data: if isinstance(data["body"], str): changes["body"] = data["body"] else: errors["body"] = ["Not a valid string."] if errors: return note, errors # nothing changes unless everything is valid return {**note, **changes}, {} # read-only fields like id and owner are ignoredWhen Best isn’t best: write the ModelSerializer instead. It reads max_length and blank from the model, trims CharFields, drops read-only and unknown fields, and gives these same error messages.
Write it yourself
Section titled “Write it yourself”Write update_note(note, data) from the task. Press Run tests to check your code.
- Look only at
titleandbodyindata. - Title: trim; blank means
["This field may not be blank."]; over 200 means["Ensure this field has no more than 200 characters."]. Body: not text means["Not a valid string."]. - With errors, return
(note, errors)unchanged. Without, return a new dict:{**note, **changes}.
A partial update, like a serializer
Ctrl + Enter runs- Not run yet:
update_note({'id': 7, 'owner': 1, 'title': 'Tea', 'body': ''}, {'title': 'Milk'})→[{'id': 7, 'owner': 1, 'title': 'Milk', 'body': ''}, {}] - Not run yet:
An owner slipped into the JSON→[{'id': 7, 'owner': 1, 'title': 'Mine now', 'body': ''}, {}] - Not run yet:
A blank title→[{'id': 7, 'owner': 1, 'title': 'Tea', 'body': ''}, {'title': ['This field may not be blank.']}] - Not run yet:
The note passed in is not changed→'Tea' - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Client sets owner | Bad values | Good | Not so good | Use it when |
|---|---|---|---|---|---|
| Okay: save every key | Yes | Saved | One line | Mass assignment | Never on user input |
| Better: writable fields | No | Saved | Stops the worst hole | No checks; changes in place | Data you already trust |
| Best: check, then build | No | Refused, with errors | All or nothing | You maintain the rules by hand | Never by hand with DRF: use a serializer |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”queryset = Note.objects.all()on a viewset. Every user can list and open every note. Overrideget_queryset()to filter by the user.- Owner from the JSON. Set it in
perform_create()fromrequest.user; never make it a writable field. - A serializer in a loop that reads relations. Nested serializers send one query per row. Use
select_relatedandprefetch_relatedinget_queryset(). - No pagination. A list of 100,000 notes in one response. Set
DEFAULT_PAGINATION_CLASSandPAGE_SIZE. - Throttling as security. It slows abuse, but the default cache is per process. Use Redis for throttles that hold across servers.
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- Who calls the API: our own front end, mobile apps, or outside developers?
- Session cookies or tokens?
- Which fields may each kind of user see and change?
A 60-second walkthrough
“In DRF, a ModelSerializer defines the JSON: which fields show and which a client may set. Read-only fields like id and owner can’t be written, which stops mass assignment. is_valid checks everything at once, and save writes. A ModelViewSet gives the CRUD actions; I override get_queryset to filter by request.user, and perform_create to set the owner. A router builds the URLs. Then permissions decide who may do what, throttling returns 429 with Retry-After, and pagination adds count, next and previous. Most of that goes in REST_FRAMEWORK settings, so every view is safe by default.”
Likely follow-ups
has_permissionvshas_object_permission? The first runs for every request; the second runs whenget_object()loads one row, so list views don’t call it.- Why does a viewset return 404, not 403, for someone else’s note? The note isn’t in that user’s queryset, so it doesn’t exist for them. That also hides which ids exist.
- How do you version a DRF API? A URL prefix like
/api/v2/, or DRF’s versioning classes. Never change a field’s meaning in place. - Serializer
validate_<field>vsvalidate? The first checks one field;validate(self, data)checks fields together, like start before end.
Practice
Section titled “Practice”Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers.
1. Owner, or read only (easy)
Section titled “1. Owner, or read only (easy)”Write allowed(user, method, note), like a DRF permission. Nobody logged in (None): no. Safe methods (GET, HEAD, OPTIONS, any case): yes if the note is shared, or the user owns it. Other methods: only the owner.
Example: Ben may GET Ana’s shared note, but not PATCH it.
Hint 1 of 3
Check for no user first.
Hint 2 of 3
method.upper() in SAFE_METHODS separates reading from changing.
Hint 3 of 3
Reading needs shared or owner; changing needs owner.
Owner, or read only
Ctrl + Enter runs- Not run yet:
Ben reads Ana’s shared note→True - Not run yet:
Ben edits Ana’s shared note→False - Not run yet:
Ben reads Ana’s private note→False - Not run yet:
Ana deletes her own note→True - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Logged in
The idea: Allow anyone who is logged in.
Visitors are kept out.
def allowed(user: dict | None, method: str, note: dict) -> bool: return user is not None- Ben edits Ana’s note
Allowed- Ben reads a shared note
Allowed- Why
- It is IsAuthenticated, and nothing more.
Where it breaks: any user can change or delete anyone’s note.
Owner only
The idea: Allow only the owner, for every method.
The waste in Okay: it never looked at the note.
Highlighted lines are new in this rung.
def allowed(user: dict | None, method: str, note: dict) -> bool: return user is not None and note["owner"] == user["id"] # owners only, even to read- Ben edits Ana’s note
Refusedwas Allowed- Ben reads a shared note
Refusedwas Allowed- Why
- Nobody touches a note they don’t own.
What changed from the previous rung
def allowed(user: dict | None, method: str, note: dict) -> bool: return user is not None return user is not None and note["owner"] == user["id"] # owners only, even to readWhere it breaks: sharing stops working: Ben can’t read a note Ana shared with him.
2. A rate throttle (medium)
Section titled “2. A rate throttle (medium)”Write throttle(history, now, rate). history holds the times, in seconds, of this user’s earlier requests, in any order. rate is like "3/min", with the period s, sec, m, min, h, hour, d or day. Allow the request if fewer than the limit fell in the last period. Return (allowed, wait), where wait is how many seconds until the oldest request in the window drops out, or 0.0 when allowed.
Example: requests at 100, 110 and 130, now=140, "3/min" gives (False, 20.0).
Hint 1 of 3
A dict from period name to seconds handles all eight names.
Hint 2 of 3
Keep the times where now - t < seconds, sorted.
Hint 3 of 3
If blocked, the oldest kept time leaves the window at oldest + seconds; wait is that minus now.
A rate throttle
Ctrl + Enter runs- Not run yet:
Three in the last minute→[False, 20] - Not run yet:
The oldest just left the window→[True, 0] - Not run yet:
Three, long ago→[True, 0] - Not run yet:
Out of order, "3/m"→[False, 20] - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Count everything
The idea: Compare the number of requests ever with the limit.
It does stop a burst.
def throttle(history: list[float], now: float, rate: str) -> tuple[bool, float]: limit = int(rate.split("/")[0]) return len(history) < limit, 0.0 # counts every request, ever- Old requests
Count for ever- Retry hint
None- Why
- It has a limit, but no window.
Where it breaks: after three requests, a user is blocked for ever.
A window, for minutes and hours
The idea: Count requests in the last 60 or 3,600 seconds.
The waste in Okay: it never forgot a request.
Highlighted lines are new in this rung.
def throttle(history: list[float], now: float, rate: str) -> tuple[bool, float]: limit, period = rate.split("/") seconds = 60 if period == "min" else 3600 recent = [t for t in history if now - t < seconds] return len(recent) < int(limit), 0.0 # no hint for when to retry- Old requests
Drop outwas Count for ever- Retry hint
None- Why
- Old requests leave the window, so users get back in.
What changed from the previous rung
def throttle(history: list[float], now: float, rate: str) -> tuple[bool, float]: limit = int(rate.split("/")[0]) return len(history) < limit, 0.0 # counts every request, ever limit, period = rate.split("/") seconds = 60 if period == "min" else 3600 recent = [t for t in history if now - t < seconds] return len(recent) < int(limit), 0.0 # no hint for when to retryWhere it breaks: "3/day" is read as an hour, "3/m" isn’t known, and blocked clients get no Retry-After, so they retry at once, over and over.
Every period, and a wait time
The idea: Map period names to seconds; count the window; if full, wait until the oldest request leaves it.
The waste in Better: it knew two periods, and told the client nothing.
Highlighted lines are new in this rung.
PERIODS = {"s": 1, "sec": 1, "m": 60, "min": 60, "h": 3600, "hour": 3600, "d": 86400, "day": 86400}
def throttle(history: list[float], now: float, rate: str) -> tuple[bool, float]: limit, period = rate.split("/") seconds = PERIODS[period] recent = sorted(t for t in history if now - t < seconds) if len(recent) < int(limit): return True, 0.0 wait = seconds - (now - recent[0]) # when the oldest request in the window drops out return False, wait- Old requests
Drop out- Retry hint
Exact secondswas None- Why
- This is DRF’s SimpleRateThrottle: a sliding window, and wait() for the Retry-After header.
What changed from the previous rung
PERIODS = {"s": 1, "sec": 1, "m": 60, "min": 60, "h": 3600, "hour": 3600, "d": 86400, "day": 86400}
def throttle(history: list[float], now: float, rate: str) -> tuple[bool, float]: limit, period = rate.split("/") seconds = 60 if period == "min" else 3600 recent = [t for t in history if now - t < seconds] return len(recent) < int(limit), 0.0 # no hint for when to retry seconds = PERIODS[period] recent = sorted(t for t in history if now - t < seconds) if len(recent) < int(limit): return True, 0.0 wait = seconds - (now - recent[0]) # when the oldest request in the window drops out return False, waitWhen Best isn’t best: keeping every time costs memory per user. At scale, a fixed window counter or a token bucket in Redis is cheaper. In DRF, set DEFAULT_THROTTLE_RATES and a shared cache.
3. Next and previous links (medium)
Section titled “3. Next and previous links (medium)”Write page_links(url, page, count, size), like DRF’s PageNumberPagination. Return count, next and previous. A link is None when there is no such page. Keep the URL’s other query parts, like ?search=tea. The link to page 1 has no page at all.
Example: page_links(URL + "?search=tea&page=2", 2, 45, 20) gives next ending ?search=tea&page=3 and previous ending ?search=tea.
Hint 1 of 3
Pages: -(-count // size), at least 1.
Hint 2 of 3
urlsplit, parse_qsl, urlencode and urlunsplit rebuild a URL with one query part changed.
Hint 3 of 3
Drop the old page, then add the new one only if it is above 1.
Next and previous links
Ctrl + Enter runs- Not run yet:
Page 1 of 3→{'count': 45, 'next': 'http://api.example.com/api/v2/notes/?page=2', 'previous': None} - Not run yet:
Page 3 of 3→{'count': 45, 'next': None, 'previous': 'http://api.example.com/api/v2/notes/?page=2'} - Not run yet:
Page 2: the link back has no page→{'count': 45, 'next': 'http://api.example.com/api/v2/notes/?page=3', 'previous': 'http://api.example.com/api/v2/notes/'} - Not run yet:
Page 2 of a search→{'count': 45, 'next': 'http://api.example.com/api/v2/notes/?search=tea&page=3', 'previous': 'http://api.example.com/api/v2/notes/?search=tea'} - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Always both links
The idea: next is page + 1, previous is page − 1.
Right for a middle page of a plain list.
def page_links(url: str, page: int, count: int, size: int) -> dict: return {"count": count, "next": f"{url}?page={page + 1}", "previous": f"{url}?page={page - 1}"}- Ends
?page=0, ?page=4- ?search=tea
Lost- Why
- It never asks how many pages there are.
Where it breaks: page 1 links to ?page=0, the last page links past the end, and a client following next never stops.
None at the ends
The idea: Count the pages; give None before the first and after the last.
The waste in Okay: it linked to pages that don’t exist.
Highlighted lines are new in this rung.
def page_links(url: str, page: int, count: int, size: int) -> dict: pages = max(1, -(-count // size)) return { "count": count, "next": f"{url}?page={page + 1}" if page < pages else None, "previous": f"{url}?page={page - 1}" if page > 1 else None, }- Ends
Nonewas ?page=0, ?page=4- ?search=tea
Lost- Why
- A client can loop until next is None.
What changed from the previous rung
def page_links(url: str, page: int, count: int, size: int) -> dict: return {"count": count, "next": f"{url}?page={page + 1}", "previous": f"{url}?page={page - 1}"} pages = max(1, -(-count // size)) return { "count": count, "next": f"{url}?page={page + 1}" if page < pages else None, "previous": f"{url}?page={page - 1}" if page > 1 else None, }Where it breaks: it builds the link from the bare URL, so a search loses its filter on page 2, and url?page=2 becomes url?page=2?page=3.
Rebuild the query
The idea: Parse the URL, swap only page (none for page 1), and put it back together.
The waste in Better: it treated the URL as text, not as parts.
Highlighted lines are new in this rung.
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
def with_page(url: str, page: int) -> str: parts = urlsplit(url) query = [(k, v) for k, v in parse_qsl(parts.query) if k != "page"] # keep ?search=... if page > 1: query.append(("page", str(page))) # page 1 is the plain URL, as in DRF return urlunsplit(parts._replace(query=urlencode(query)))
def page_links(url: str, page: int, count: int, size: int) -> dict: pages = max(1, -(-count // size)) return { "count": count, "next": with_page(url, page + 1) if page < pages else None, "previous": with_page(url, page - 1) if page > 1 else None, }- Ends
None- ?search=tea
Keptwas Lost- Why
- Every other query part survives, and page 1 has one clean URL.
What changed from the previous rung
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
def with_page(url: str, page: int) -> str: parts = urlsplit(url) query = [(k, v) for k, v in parse_qsl(parts.query) if k != "page"] # keep ?search=... if page > 1: query.append(("page", str(page))) # page 1 is the plain URL, as in DRF return urlunsplit(parts._replace(query=urlencode(query)))
def page_links(url: str, page: int, count: int, size: int) -> dict: pages = max(1, -(-count // size)) return { "count": count, "next": f"{url}?page={page + 1}" if page < pages else None, "previous": f"{url}?page={page - 1}" if page > 1 else None, "next": with_page(url, page + 1) if page < pages else None, "previous": with_page(url, page - 1) if page > 1 else None, }When Best isn’t best: set DEFAULT_PAGINATION_CLASS and DRF builds these from request.build_absolute_uri(). For big or fast-changing lists, CursorPagination avoids the repeats that page numbers show.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”ModelSerializer: listfieldsby name;read_only_fieldsfor id, timestamps; owner set inperform_create.is_valid()checks all fields;errorsby field;save()writes.validate_<field>for your own rules.ModelViewSet+ router = CRUD URLs. Overrideget_queryset()to filter byrequest.user.- Permissions (who), throttling (how often, 429 + Retry-After), pagination (which slice). Defaults in
REST_FRAMEWORK. - Never
fields = "__all__"on user input; never trust ids or owners from the JSON.
Go deeper
Section titled “Go deeper”- DRF: serializers, viewsets, permissions, throttling and pagination.
- OWASP: mass assignment.
- The runnable example:
examples/django/notes/api_v2.py, at/api/v2/notes/, with 6 tests. - Next on the Frameworks roadmap: FastAPI, which checks input from type hints.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started