Skip to content

Resource and endpoint design

  • Lesson 7
  • 35 min
  • Module 3: Design a usable contract

What you will be able to do

Give the API routes that a stranger can guess. Also give it a version that lets you change it later without breaking the programs that already use it. Then write down the contract: every request, and the exact response for success and for each failure.

Before you startWhat to know and to have readyAbout 35 minutes

What you need to know

What you need on your computer

Tested with Python 3.14.6 and FastAPI 0.142.2 on macOS.

When another program uses your API, its code depends on your paths, your field names and your status codes. If you rename category to team, that program breaks, and you may not even know that it exists. The paths, fields and status codes together are the API’s contract: a promise to every client (a program that calls the API).

So design the contract on purpose, before many clients depend on it. This lesson gives four rules.

A resource is a thing that the API offers: tickets, categories, a classification. A REST path names the resource with a noun, and the method says what to do with it:

Instead of Write Why
GET /getCategories GET /v1/categories The method already says “get”.
POST /tickets/delete/T-1 DELETE /v1/tickets/T-1 The action is the method.
GET /ticket/T-1 GET /v1/tickets/T-1 Collections are plural, so /tickets and /tickets/T-1 read as “all” and “one”.

One endpoint in this API, /classify, is a verb. That is an accepted exception: classification is an action that creates nothing, and a noun such as /classifications would suggest that the API stores them. Choose the clearest name, and then keep to it.

Choose a style once, and use it in every path and field:

  • Paths: small letters, plural nouns, - between words: /v1/ticket-categories.
  • JSON fields: small letters with _ between words: model_version. That is the Python style, so the models need no translation.
  • Values that a program compares: fixed words in small letters: "billing", not "Billing".

A client developer who has seen one endpoint can then guess the others correctly.

Some changes break clients. Others do not:

Breaking: a client can fail Not breaking: old clients keep working
Rename or remove a field, path or value Add a new endpoint
Make an optional request field required Add an optional request field
Change a field’s type or meaning Add a field to a response
Return a different status code for the same case Accept more values than before

For a breaking change, you publish a new version of the API, and keep the old one running until its clients have moved. The simplest way is a version at the start of the path: /v1/classify now, /v2/classify later. Clients see in every URL which version they use.

/health stays without a version. It is about the running service, not about the contract, and hosting tools expect it in a fixed place.

A contract table lists, for each endpoint, the requests that matter and the exact response for each. It is the plan for the code, the documentation and the tests. Here is the table for the classify endpoint, with the error format that you build in the next lesson:

Request Status Response body
POST /v1/classify {"subject": "Charged twice for March"} 200 {"category": "billing", "priority": 1, "confidence": 0.7, "model_version": "keywords-1.0"}
{"subject": "Hello"} (no keyword) 200 {"category": "other", "priority": 3, ...}
{"subject": ""} 422 {"error": {"code": "invalid_request", "fields": ["subject"], ...}}
No subject field 422 Same shape, "fields": ["subject"]
A field that the model does not list, such as "colour" 422 Same shape, "fields": ["colour"]
Body that is not valid JSON 422 Same shape, "fields": ["body"]
GET /v1/classify 405 {"error": {"code": "method_not_allowed", ...}}

Writing the failure rows first is a good habit. Most bugs and most client questions are about them.

Worked example: a version prefix and a second resource

Section titled “Worked example: a version prefix and a second resource”

Amira adds the version to every route in routes.py, with one change: the router gets a prefix. She also adds a second resource, the list of categories. With it, a client can show them in a menu without copying them from the documentation:

ticket_api/routes.py (changes)
from typing import Annotated, get_args
from ticket_api.models import Category, CategoryList, Classification, TicketIn
router = APIRouter(prefix="/v1", tags=["tickets"])
@router.get("/categories")
def list_categories() -> CategoryList:
return CategoryList(categories=list(get_args(Category)))
ticket_api/models.py (add at the end)
class CategoryList(BaseModel):
categories: list[Category]

prefix="/v1" puts /v1 in front of every path in this router: /classify becomes /v1/classify. tags groups the routes in the documentation (Module 4). get_args(Category) gives the values of the Literal type, so the list always matches the response model: there is one place to change.

The response is an object with a field, {"categories": [...]}, not a bare list. Then a new field, such as a description for each category, can be added later without a breaking change.

Terminal (on Windows: curl.exe)
curl http://127.0.0.1:8000/v1/categories
Output
{"categories":["billing","login","shipping","account","other"]}

The old path no longer exists:

Terminal (on Windows: curl.exe)
curl -X POST http://127.0.0.1:8000/classify -H "Content-Type: application/json" --data @requests/billing.json
Output
{"detail":"Not Found"}
  1. Change routes.py and models.py as in the worked example. Save.
  2. Call GET /v1/categories. You see the five categories.
  3. Send requests/billing.json to /v1/classify. The result is the same as before.
  4. Send it to the old /classify. You get 404.
  5. Call GET /health. It still works, without /v1.

Your turn: the contract table for categories

Section titled “Your turn: the contract table for categories”

Write the contract table for GET /v1/categories, in the same form as the table above. Include at least one failure row. Then check every row with curl.

Show one answer
Request Status Response body
GET /v1/categories 200 {"categories": ["billing", "login", "shipping", "account", "other"]}
GET /v1/categories?lang=fr (an unknown query parameter) 200 The same. Unknown query parameters are ignored.
POST /v1/categories 405 {"error": {"code": "method_not_allowed", ...}} after the next lesson. Now: {"detail":"Method Not Allowed"}
GET /v1/category (singular) 404 {"error": {"code": "not_found", ...}} after the next lesson. Now: {"detail":"Not Found"}

The table shows a gap: today, the error bodies have a different shape from the one that the contract promises. The next lesson closes that gap.

Common mistake

A small rename that breaks a client

Symptom: after a “small clean-up”, a client team reports that their program shows an error, or shows empty values. But your API “works”: every request gets 200.

Cause: the clean-up renamed a field, for example model_version to version, or a value, for example "login" to "sign-in". The response is valid for your new model, but the client still reads the old name, and gets nothing. A rename is a breaking change, even when nothing fails on your side.

Fix:

  1. Put the old name back now, so the clients work again.
  2. If the new name is really needed, add it next to the old one, and tell the clients. Remove the old one only in the next version (/v2).
  3. In Module 4, you add a test that fails when the contract changes, so this mistake cannot reach the clients unnoticed.

Check your understanding

Which of these changes to /v1/classify are breaking? (a) Add an optional request field lang. (b) Make body required. (c) Add a response field explanation. (d) Return 400 instead of 422 for an empty subject.

Show the answer

(b) and (d) are breaking. (b): clients that do not send body now get an error. (d): clients that check for 422 now miss the error. (a) and (c) are not breaking: old clients do not send lang and do not read explanation, so they keep working. Breaking changes go to /v2.

Recap

  • The paths, fields and status codes are a contract with every client. Design it on purpose.
  • Paths name resources with plural nouns. The method says what to do.
  • Keep one naming style for paths, fields and values.
  • Put a version in the path (/v1). A breaking change gets a new version. Adding something does not.
  • Write a contract table with the failure rows first.

Next, you give every error the same shape, and make sure that no error leaks the inside of the server.

Next: Validation and error responses