Index Ai Academy
Phase 8 · APIs · Design at scale

Capstone: a complete API

An authenticated, validated, paginated API with tests.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. The brief
  2. Order of checks on each request
  3. What a stranger can learn
  4. One entry point, many tests
  5. Testing an API through one function
  6. Before you call it done
  7. Mistakes this project is designed to catch
  8. 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:

endpointneeds scopesuccesserrors
POST /products/{sku}/reviewsreviews:write201 + the review401, 403, 404 (no such product), 422 (bad body)
GET /products/{sku}/reviews?limit=&cursor=reviews:read200 + a page401, 403, 404, 400 (bad cursor or limit)
DELETE /reviews/{id}reviews:write204401, 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. rating is an integer from 1 to 5, text is 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-Key header, so the mobile app's retries can't post the same review twice.
  • Errors: always {"error": {"type": ..., "message": ...}}.
Quick check
Lin's key tries to delete a review Ada wrote. Why answer 404 rather than 403?
  1. So nobody can learn which review ids exist by probing for 403s
  2. Because the review really doesn't exist
  3. 404 is faster to compute
  4. Because 403 is only for admins
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:

  1. Route: does the method and path exist? (404 or 405)
  2. Authenticate: is there a valid key? (401). Do this before anything that touches data.
  3. Authorize the action: does the key have the scope? (403)
  4. Validate input: the body, the query parameters, the cursor. (422 or 400)
  5. Load and check objects: does the product exist? Is this review the caller's? (404)
  6. 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.

Worked example

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))
It runs right here in your browser, with nothing to install.

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.

Quick check
What does this print?
def check(has_key, valid_body):
    if not has_key: return 401
    if not valid_body: return 422
    return 200
print(check(False, False))
  1. 400
  2. 200
  3. 401
  4. 422
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.

Try it now

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.

Quick check
A key without the reviews:write scope sends a badly formed body to create a review. What should it get?
  1. 401
  2. 422, listing what's wrong with the body
  3. 403, because the scope is checked before the body is validated
  4. 201
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.

Worked example

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)
In the lesson you can change this and run it.

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.

Quick check
Which of these is on the lesson's review checklist for any API?
  1. Each handler picks its own error shape
  2. Every endpoint returns 200, so clients never break
  3. Column names in SQL come straight from the request
  4. Lists are paginated with a limit cap, so nothing returns unbounded data
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

Create a free account

Try a quiz question

In what order should an endpoint check things?

  1. Route, authenticate, authorize (scope), validate input, load and check objects, then do the work
  2. Validate input, do the work, and then check the key, so that clients with bad input get told quickly
  3. Do the work first and authenticate afterward, undoing the work if the key turns out to be invalid
  4. The order doesn't matter, as long as every check runs before the response is sent back to the client

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson