Capstone: a complete API
An authenticated, validated, paginated API with tests.
Create a free account Try part of it here
Why it matters
Each lesson in this phase solved one problem. Real APIs have to solve all of them at once, on every request, without the parts getting in each other's way. Think of it as the point where you stop practicing single moves and play a full game. This capstone builds a product reviews API for the practice shop. It covers the main things you'd be expected to get right building a service at work, and it's the shape of the backend an AI feature usually sits behind.
What you'll be able to do
- Design a small API end to end: resources, routes, schemas, errors and auth
- Combine authentication, scopes and object-level checks on every endpoint
- Wire validation, persistence, cursor pagination and idempotency into one service
- Test the whole API through a single entry point
What it covers
- The brief
- Order of checks on each request
- What a stranger can learn
- One entry point, many tests
- Testing an API through one function
- Before you call it done
- Mistakes this project is designed to catch
- One test per rule
The brief
"Customers should be able to post reviews of products through our API, read them page by page, and delete their own. Partner apps will call it with API keys, and our mobile app retries on bad connections."
The design
Before you write code, turn that brief into a table, the way you'd sketch a floor plan before building. Each row is an endpoint, and the columns say what it needs and what it answers:
| endpoint | needs scope | success | errors |
|---|---|---|---|
POST /products/{sku}/reviews | reviews:write | 201 + the review | 401, 403, 404 (no such product), 422 (bad body) |
GET /products/{sku}/reviews?limit=&cursor= | reviews:read | 200 + a page | 401, 403, 404, 400 (bad cursor or limit) |
DELETE /reviews/{id} | reviews:write | 204 | 401, 403, 404 (no such review, or not yours) |
Decisions, each from an earlier lesson:
- Auth:
Authorization: Bearer <key>. Each key has an owner and scopes. A missing or unknown key is 401; a missing scope is 403. - Object-level check: you may only delete reviews you wrote. Someone else's review answers 404, so ids can't be probed.
- Validation: a JSON Schema for the body.
ratingis an integer from 1 to 5,textis 1 to 500 characters, and nothing else is allowed. Failures are 422 with details. - Pagination: cursor-based, ordered by review id, newest last.
- Idempotency: POST accepts an
Idempotency-Keyheader, so the mobile app's retries can't post the same review twice. - Errors: always
{"error": {"type": ..., "message": ...}}.
Show the answer
So nobody can learn which review ids exist by probing for 403s
A 403 would confirm the id exists. Answering 404 for "not yours" gives an outsider nothing to learn.
Order of checks on each request
An airport runs its checks in a fixed order: passport first, then boarding pass, then your bag, and only then the gate. Nobody inspects your luggage before they know you're allowed to travel. An API needs the same discipline, both for security and for clear errors:
- Route: does the method and path exist? (404 or 405)
- Authenticate: is there a valid key? (401). Do this before anything that touches data.
- Authorize the action: does the key have the scope? (403)
- Validate input: the body, the query parameters, the cursor. (422 or 400)
- Load and check objects: does the product exist? Is this review the caller's? (404)
- Do the work in one transaction, and build the response.
Checking the scope before validating means an unauthorized caller learns nothing about your schemas. Validating before touching the database means bad input never reaches SQL.
In plain words: ask "who are you?" and "may you do this?" before you look at anything they sent.
What a stranger can learn
Run the checks in the wrong order and your errors leak information. Here are two handlers given the same request from someone with no key.
KEYS = {"key-123"}
PRODUCTS = {7: "Tent"}
def wrong_order(key, product_id):
if product_id not in PRODUCTS:
return 404
if key not in KEYS:
return 401
return 200
def right_order(key, product_id):
if key not in KEYS:
return 401
if product_id not in PRODUCTS:
return 404
return 200
for pid in [7, 8]:
print("product", pid, "wrong order:", wrong_order(None, pid), " right order:", right_order(None, pid))With the wrong order, a stranger can tell that product 7 exists and product 8 doesn't, without any key. The right order gives both the same 401. The same slip can reveal which customer ids or email addresses are real.
def check(has_key, valid_body): if not has_key: return 401 if not valid_body: return 422 return 200 print(check(False, False))
Show the answer
401
has_key is False, so the first test returns 401 before the body is ever looked at. Authentication comes first, so someone without a key learns nothing about your validation.
The order is about security as well as clear errors: an unauthorized caller learns nothing.
One entry point, many tests
The whole service is a class with one method, handle(method, path, headers, body), returning (status, headers, body). Picture one front counter that every request walks up to. Production wraps it in a real server, as you saw with WSGI. Tests walk up to the same counter directly, with an in-memory database, so every requirement in the table above becomes a one-line check.
reviews:write scope sends a badly formed body to create a review. What should it get?Show the answer
403, because the scope is checked before the body is validated
Authorizing before validating means an unauthorized caller learns nothing about your schemas.
Testing an API through one function
A miniature version of the pattern: a service with one endpoint, and the tests a reviewer would expect.
class Pinger: def __init__(self, keys): self.keys = keys def handle(self, method, path, headers, body=None): if (method, path) != ("GET", "/ping"): return 404, {}, {"error": {"type": "not_found_error", "message": "no such route"}} key = headers.get("authorization", "").removeprefix("Bearer ") if key not in self.keys: return 401, {}, {"error": {"type": "authentication_error", "message": "bad key"}} return 200, {}, {"pong": self.keys[key]} api = Pinger({"k_ada": "ada"}) tests = [ (("GET", "/ping", {"authorization": "Bearer k_ada"}), 200), (("GET", "/ping", {}), 401), (("GET", "/pong", {"authorization": "Bearer k_ada"}), 404), ] for args, want in tests: got = api.handle(*args)[0] print("PASS" if got == want else "FAIL", args[:2], got)
Each row is a request and the status it should get. Once the API is one function, the test list reads like the design table. Writing that table first, then the code, is TDD at the scale of a whole service.
Before you call it done
Pilots read a checklist before every flight, however many hours they've flown. Here's a review checklist for any API you build:
- Every endpoint authenticates, checks scopes, and checks object ownership.
- Every input is validated, and every error uses the one error shape.
- Lists are paginated with a limit cap; nothing returns unbounded data.
- Writes that clients may retry accept idempotency keys.
- SQL uses placeholders, and any column names come from allowlists.
- Responses carry request ids; logs are structured and free of secrets.
- There's an OpenAPI spec, and CI runs the tests and a breaking-change check.
The exercise builds the core of the reviews API. Helpers from earlier lessons are provided in compact form, so you can focus on putting them together correctly.
Show the answer
Lists are paginated with a limit cap, so nothing returns unbounded data
Unbounded lists are a common way to take an API down. The other three are mistakes the lesson warns against.
Mistakes this project is designed to catch
Don't validate before you authorize. A door guard shouldn't explain what's wrong with your fake ID. If an unauthorized caller gets a detailed list of what's wrong with their request, you've taught a stranger how your API works. Check the scope first, then validate.
403 can confirm that something exists. A clerk who says "I can't show you that file" has told you there's a file. If a user asks for someone else's order and gets 403, they've learned that the order exists. Answer 404, exactly as if it didn't, and no one can use your API to probe for valid ids.
Keep idempotency keys per client. Two restaurants can hand out the same ticket number, and neither should give you the other's meal. If two clients happen to use the same key and you share a single list, the second client receives the first client's response, which might include someone else's data. Store each key along with the client it belongs to.
Don't count rows to find out if there are more. To learn whether a book has another page, you turn one page. You don't count the rest. SELECT COUNT(*) on a large table is slow, and you only need a yes or no. Ask for one row more than the page size. If it comes back, there's another page, and you drop it from the response. It's a small trick that works at any size.
One test per rule
Each rule in the brief deserves its own test, so a failure tells you exactly what broke.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
In what order should an endpoint check things?