◐ ai-teacher

🐍 Backend with Python · Chapter 5

Designing a REST API people can use

An API is a promise you can't take back: the day a mobile app ships against your URLs, every odd name and wrong status code is yours to support for years.

Study this lesson free in the app →

The basics

A REST API exposes your data as resources — nouns with addresses like /v1/rides/42 — and lets clients act on them with the handful of HTTP methods they already know.

The problem it solves: without conventions every endpoint is a one-off (/getRideById, /cancelRideNow) that each client team must learn, guess at and break on, and that no cache, retry or monitoring tool understands.

The mental model: a railway parcel office. Every parcel has an address, and the counter does only a few things — accept, look up, update, return. Nobody opens a new counter per parcel. Your URLs are the addresses; the methods are the counter.

Words you must know 5

Resource
a thing with an identity: a ride, a driver, a payment. Plural noun in the URL: /rides/42, never /getRide?id=42.
Status code
the first thing every client reads. 201 created, 404 not there, 409 clashes with current state, 422 your input is wrong.
Pagination
a list never returns everything; it returns a page plus a cursor for the next one.
Error envelope
every error has the same JSON shape, so clients write one handler.
Versioning
a /v1/ prefix so you can change your mind without breaking apps already on lakhs of phones.

Explained

POST /api/getRideDetails       {"id": 42}
200 OK                         {"success": false, "error": "not found"}

The mobile client's retry logic sees 200 and doesn't retry. The CDN caches nothing because it's a POST. The dashboard counts it as a success. Three tools, three wrong answers — because the API said one thing in the status line and another in the body.

GET /v1/rides/42 → 404 tells all of them the truth with zero custom code. Good API design is mostly being boring on purpose.

The parcel office counter

At a railway parcel office in Vijayawada, every parcel gets a docket number. The counter clerk does exactly four things: accept a parcel and hand you a docket, look up a docket, update where it's going, hand it back.

A trader who has never been to this office still knows how to use it, because every parcel office works the same way. If the clerk invented a new form for "parcels going to Guntur on Tuesdays", every trader would need training.

And when a docket doesn't exist, the clerk says "no such docket" — he doesn't smile, say "done!", and hand you an empty box.

That is a REST API: addresses for things (dockets → URLs), a tiny fixed set of actions (methods), and honest answers (status codes).

Nouns, methods, codes

| You want to… | Request | Success | | --- | --- | --- | | list rides | GET /v1/rides?limit=20 | 200 + page | | book a ride | POST /v1/rides | 201 + Location | | read one | GET /v1/rides/42 | 200 / 404 | | change some fields | PATCH /v1/rides/42 | 200 | | replace it whole | PUT /v1/rides/42 | 200 | | delete it | DELETE /v1/rides/42 | 204 | | a driver's rides | GET /v1/drivers/7/rides | 200 |

Actions that aren't plain CRUD — cancel, refund, assign — become either a state change (PATCH with {"status": "cancelled"}) or a sub-resource you create (POST /v1/rides/42/cancellation). If cancelling isn't allowed in the current state (already completed), answer 409 Conflict, not 400.

Safe, idempotent and retryable methods were be-002's lesson; here they become naming rules: reads are GET, so they can be cached and retried for free.

Create and read, the boring way

from fastapi import FastAPI, HTTPException, Response
from pydantic import BaseModel, Field

app = FastAPI()
RIDES: dict[int, dict] = {}

class RideIn(BaseModel):
    rider_id: int
    pickup: str = Field(min_length=1)
    drop: str = Field(min_length=1)

class Ride(RideIn):
    id: int
    status: str = "requested"

@app.post("/v1/rides", status_code=201, response_model=Ride)
def create_ride(body: RideIn, response: Response):
    ride_id = len(RIDES) + 1
    ride = Ride(id=ride_id, **body.model_dump())
    RIDES[ride_id] = ride.model_dump()
    response.headers["Location"] = f"/v1/rides/{ride_id}"
    return ride

@app.get("/v1/rides/{ride_id}", response_model=Ride)
def get_ride(ride_id: int):
    if ride_id not in RIDES:
        raise HTTPException(status_code=404, detail="ride not found")
    return RIDES[ride_id]

Run against TestClient:

POST /v1/rides {valid}          → 201, Location: /v1/rides/1
GET  /v1/rides/1                → 200
GET  /v1/rides/99               → 404 {'detail': 'ride not found'}
POST /v1/rides {"pickup": ""}   → 422 ['string_too_short', 'missing']
DELETE /v1/rides/1              → 405 (no DELETE route on that path)

Follow one booking through the endpoint

Step 2 is the one to remember: validation is the framework's job, and a 422 means your handler never ran.

Which status code? Ask in this order

Every check before the last step is cheap and touches nothing. Fail as early as you can, with the most specific code you can.

The ride that appeared twice

The rides list is newest-first, paged with ?limit=3&offset=…. Rides 100–109 exist.

p1 = page_offset(limit=3, offset=0)   # [109, 108, 107]
# a new ride, 110, is booked right now
p2 = page_offset(limit=3, offset=3)

How APIs become painful

Words to know

ResourceA thing with an identity the client cares about — a ride, a driver — addressed by a URL like /v1/rides/42.
CollectionThe plural URL (/v1/rides): GET lists it, POST adds to it.
Location headerSent with 201 Created; the URL of the resource just made, so clients never build URLs themselves.
Cursor paginationAsking for the next page relative to the last item seen (id < 107) instead of by position; stable under inserts.
Error envelopeOne JSON shape for every error, e.g. {"error": {"code": 404, "message": "…"}}.
Breaking changeAny change that makes an existing client fail: removing or renaming a field, changing a type or a status code.
409 ConflictThe request is valid but clashes with the resource's current state.

In two minutes

Test yourself

1. What should POST /v1/rides return when it successfully books a ride?

  1. 200 with the ride id in the body
  2. 204 with no body
  3. 201 with the ride and a Location header
  4. 202 Accepted
Show the answer

201 with the ride and a Location header — 201 Created says a new resource exists, and Location says where. 200 works but throws away information; 204 has no body to read the id from; 202 means 'queued, not done yet' — be-010 territory.

2. Why is GET /v1/rides/42 better than POST /getRide with {"id": 42}?

  1. GET is safe, so caches, browsers and retry logic can handle it with no custom code
  2. POST bodies are limited to 1 KB
  3. GET is encrypted and POST is not
  4. FastAPI can't read POST bodies
Show the answer

GET is safe, so caches, browsers and retry logic can handle it with no custom code — A read declared as GET is understood by every HTTP tool: it can be cached, prefetched and retried safely. Hiding a read behind POST forfeits all of that. Both are equally encrypted under HTTPS.

3. Why should a missing ride return 404 instead of 200 {"success": false}?

  1. 404 responses are smaller
  2. Browsers block 200 responses with errors
  3. FastAPI raises if you return success: false
  4. Clients, caches and monitoring read the status code, and 200 tells all of them it worked
Show the answer

Clients, caches and monitoring read the status code, and 200 tells all of them it worked — The status line is the part every tool understands. A 200 with an error body is counted as success by dashboards, cached by proxies and never retried — the information is in the one place nobody generic looks.

4. How should you expose 'cancel a ride' in a resource-oriented API?

  1. GET /v1/rides/42/cancel
  2. PATCH /v1/rides/42 with {"status": "cancelled"}, or POST /v1/rides/42/cancellation
  3. POST /v1/cancelRide?id=42
  4. DELETE /v1/rides — with the id in the body
Show the answer

PATCH /v1/rides/42 with {"status": "cancelled"}, or POST /v1/rides/42/cancellation — Cancel is a state change of an existing ride, or the creation of a cancellation record. A GET that changes state is dangerous — crawlers and prefetchers follow GETs. Verbs in URLs throw away the uniform interface.

5. What is the difference between PUT and PATCH on /v1/rides/42?

  1. PUT replaces the whole resource; PATCH changes only the fields sent
  2. PUT creates, PATCH deletes
  3. They are aliases in FastAPI
  4. PATCH must send every field; PUT may send some
Show the answer

PUT replaces the whole resource; PATCH changes only the fields sent — PUT says 'here is the full new state'; fields you leave out are gone. PATCH says 'change just these'. That's why a PATCH handler must use model_dump(exclude_unset=True).

6. What happens when an endpoint declares ride_id: int and a client calls GET /v1/rides/abc?

  1. The handler runs with ride_id = 0
  2. 500 Internal Server Error
  3. 422, before the handler runs, with error type int_parsing
  4. 404 Not Found
Show the answer

422, before the handler runs, with error type int_parsing — FastAPI validates path parameters against the type hint first. 'abc' fails int parsing, so the client gets 422 with the field location — your code never sees it. 404 is tempting, but the route matched; only the value was bad.

There are 9 questions, 2 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →