Resource and endpoint design
- Lesson 7
- 35 min
- Module 3: Design a usable contract
Lesson 7 of 21 · Build and Deploy Your First APIResource and endpoint design
1. Understand the web request
2. Build a small FastAPI service
3. Design a usable contract
4. Document the API with OpenAPI and Swagger UI
5. Explore and test with Postman
6. Test and harden the service
7. Deploy and verify
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 ready
What you need to know
- Methods, and path and query parameters, from HTTP fundamentals: Methods and HTTP fundamentals: Path parameters and query parameters.
- Which module holds the routes, the models and the classifier, from Separate concerns: Three concerns, three modules.
What you need on your computer
- The package
ticket_apiwithmain.py,models.py,routes.pyandclassifier.py. You made them in Separate concerns: Guided practice: split the project. If you addedrefundin that lesson’s “Your turn” task, undo both changes, as the task says. requests/billing.json, from Request and response models: Guided practice: build and call the endpoint.- The development server runs, as in Environment and first endpoint: Step 3: start the server.
- If your project is missing or broken, copy the snapshot
end-of-m02, the project at the end of Module 2. Use a snapshot has the steps.
Tested with Python 3.14.6 and FastAPI 0.142.2 on macOS.
An API is a promise
Section titled “An API is a promise”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.
Rule 1: paths name resources
Section titled “Rule 1: paths name resources”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.
Rule 2: one naming style everywhere
Section titled “Rule 2: one naming style everywhere”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.
Rule 3: a version in the path
Section titled “Rule 3: a version in the path”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.
Rule 4: write the contract as a table
Section titled “Rule 4: write the contract as a table”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:
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)))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.
curl http://127.0.0.1:8000/v1/categories{"categories":["billing","login","shipping","account","other"]}The old path no longer exists:
curl -X POST http://127.0.0.1:8000/classify -H "Content-Type: application/json" --data @requests/billing.json{"detail":"Not Found"}Guided practice: version the API
Section titled “Guided practice: version the API”- Change
routes.pyandmodels.pyas in the worked example. Save. - Call
GET /v1/categories. You see the five categories. - Send
requests/billing.jsonto/v1/classify. The result is the same as before. - Send it to the old
/classify. You get404. - 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:
- Put the old name back now, so the clients work again.
- 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). - 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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.