Request and response models
- Lesson 5
- 40 min
- Module 2: Build a small FastAPI service
Lesson 5 of 21 · Build and Deploy Your First APIRequest and response models
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
Add the endpoint that does the real work: POST /classify reads a ticket as JSON, and returns its category and priority. Typed models check every ticket before your code sees it, so a missing subject becomes a clear 422, not a crash.
Before you startWhat to know and to have ready
What you need to know
- Dataclasses and type hints, from Structured records: Dataclasses: a type for a ticket and Structured records: Type hints.
- What FastAPI does with a request, from Environment and first endpoint: What FastAPI does.
- The status codes
200and422, from HTTP fundamentals: Status codes.
What you need on your computer
- The project
~/projects/ticket-api, with its virtual environment and FastAPI. You made it in Environment and first endpoint: Step 1: make the project. ticket_api/main.pywithGET /health, from Environment and first endpoint: Step 2: the first endpoint. This lesson replacesmain.py, so an extra endpoint such as/versiondoes not matter.- The development server runs in the project folder, with the environment active. Start it as in Environment and first endpoint: Step 3: start the server, and check it as in Step 4: check it.
- If the project is missing or does not start, do steps 1 to 4 of Environment and first endpoint again.
- A second terminal in the project folder, for curl. On Windows, type
curl.exe.
Tested with Python 3.14.6, FastAPI 0.142.2 and Pydantic 2.13 on macOS.
Models describe the data
Section titled “Models describe the data”In the Python course, find_problems checked each ticket by hand: is the subject there, is the priority a number from 1 to 3? An API gets JSON from programs that you do not control, so it needs the same checks, for every field, on every request.
FastAPI uses Pydantic for this. A Pydantic model is a class that lists the fields of some data, with a type and limits for each one. It looks like a dataclass:
from pydantic import BaseModel, Field
class TicketIn(BaseModel): subject: str = Field(min_length=1, max_length=120) body: str = Field(default="", max_length=5000)| Part | Meaning |
|---|---|
class TicketIn(BaseModel) |
A model for the input of the endpoint: one ticket. |
subject: str |
The field must be a string. There is no default, so it is required. |
Field(min_length=1, max_length=120) |
Limits: at least 1 character, at most 120. |
body: str = Field(default="", ...) |
Optional. If the client leaves it out, it is "". |
An endpoint function can have a parameter with a model type. Then FastAPI reads the JSON body, checks it against the model, and only then calls your function. If the body does not match, FastAPI answers 422 and your function never runs.
The response model
Section titled “The response model”The output gets a model too. FastAPI reads the function’s return type, and uses it to check the result and to write the JSON:
from typing import Literal
Category = Literal["billing", "login", "shipping", "account", "other"]
class Classification(BaseModel): category: Category priority: int = Field(ge=1, le=3) confidence: float = Field(ge=0, le=1) model_version: strLiteral[...] means “only one of these values”. ge and le mean “greater than or equal to” and “less than or equal to”. The response model is a promise to every client: the category is one of five words, and the priority is always 1, 2 or 3. If your code tries to break the promise, Pydantic stops it (see the common mistake below).
Worked example: the classify endpoint
Section titled “Worked example: the classify endpoint”The classification itself is simple for now: the first category whose keyword appears in the text wins. The next lesson moves it into its own module and improves it. Here is the whole main.py:
from typing import Literal
from fastapi import FastAPIfrom pydantic import BaseModel, Field
app = FastAPI(title="Ticket Classifier API")
Category = Literal["billing", "login", "shipping", "account", "other"]
KEYWORDS = { "billing": ("invoice", "charged", "refund", "payment"), "login": ("password", "sign in", "log in", "two-factor", "locked out"), "shipping": ("parcel", "delivery", "courier", "tracking"), "account": ("account", "company name", "user"),}
class TicketIn(BaseModel): subject: str = Field(min_length=1, max_length=120) body: str = Field(default="", max_length=5000)
class Classification(BaseModel): category: Category priority: int = Field(ge=1, le=3) confidence: float = Field(ge=0, le=1) model_version: str
@app.get("/health")def health() -> dict[str, str]: return {"status": "ok"}
@app.post("/classify")def classify(ticket: TicketIn) -> Classification: text = f"{ticket.subject}\n{ticket.body}".lower() for category, words in KEYWORDS.items(): if any(word in text for word in words): return Classification( category=category, priority=2, confidence=0.7, model_version="keywords-0.1" ) return Classification( category="other", priority=3, confidence=0.2, model_version="keywords-0.1" )Inside the function, ticket is a TicketIn object, already checked: ticket.subject is a string of 1 to 120 characters. The function does not check anything itself. It returns a Classification, and FastAPI turns it into JSON.
Valid and invalid requests
Section titled “Valid and invalid requests”Amira keeps her test bodies as files in a folder requests, so each one is easy to send again. A valid ticket:
{"subject": "Charged twice for March", "body": "My card was charged two times for the same invoice."}curl -i -X POST http://127.0.0.1:8000/classify -H "Content-Type: application/json" --data @requests/billing.jsonHTTP/1.1 200 OK{"category":"billing","priority":2,"confidence":0.7,"model_version":"keywords-0.1"}Then three invalid tickets. She predicts the status code before each one:
| File | Body | Response |
|---|---|---|
empty-subject.json |
{"subject": ""} |
422: "type":"string_too_short", "loc":["body","subject"], "msg":"String should have at least 1 character" |
no-subject.json |
{"body": "Hello"} |
422: "type":"missing", "loc":["body","subject"], "msg":"Field required" |
wrong-types.json |
{"subject": 42, "body": ["a"]} |
422, with two errors: subject and body are not strings |
Here is the full body for the empty subject:
{"detail":[{"type":"string_too_short","loc":["body","subject"],"msg":"String should have at least 1 character","input":"","ctx":{"min_length":1}}]}loc is the location of the problem: in the body, the field subject. FastAPI reports every problem at once, as find_problems did in the Python course. In Module 3, you replace this format with a shorter one that is the same for every error.
Guided practice: build and call the endpoint
Section titled “Guided practice: build and call the endpoint”-
Replace
ticket_api/main.pywith the worked example, and save it. The development server restarts. -
Make the folder
requestswith the four files from the table:billing.json,empty-subject.json,no-subject.jsonandwrong-types.json. -
Send each file with curl, as in the worked example. Before each one, write down the status code that you expect.
billing.jsongives200. The three others give422, with thelocandmsgfrom the table. -
Send a body with an extra field:
{"subject": "Refund", "colour": "red"}.It gives
200. Pydantic ignores fields that the model does not list. Module 3 makes the API reject them, so a typing mistake in a field name is not silently ignored. -
Send a subject with 121 characters. (Make the file with Python:
python -c "import json; print(json.dumps({'subject': 'x' * 121}))" > requests/long.json.)It gives
422withstring_too_long. Then try exactly 120 characters:200. You tested both sides of the limit.
Your turn: a new optional field
Section titled “Your turn: a new optional field”The help desk sends the channel of each ticket: email, phone or chat. Add an optional field channel to TicketIn, with the default "email", that accepts only those three values. Then add the channel to the response as a field channel in Classification, so a client sees which channel the API used.
Test four bodies: with "channel": "chat", with no channel, with "channel": "fax", and with "channel": "Chat". Predict each result first.
Show one answer
Channel = Literal["email", "phone", "chat"]
class TicketIn(BaseModel): subject: str = Field(min_length=1, max_length=120) body: str = Field(default="", max_length=5000) channel: Channel = "email"
class Classification(BaseModel): category: Category priority: int = Field(ge=1, le=3) confidence: float = Field(ge=0, le=1) model_version: str channel: ChannelIn classify, pass channel=ticket.channel to both Classification(...) calls. "chat" gives 200 with "channel":"chat". No channel gives "channel":"email". "fax" and "Chat" both give 422 with literal_error: a Literal compares exactly, so capital letters count. Remove the field again before the next lesson. The rest of the course does not use it.
Common mistake
500 Internal Server Error from your own result
Symptom: a request that looks valid gets 500 Internal Server Error, with the body Internal Server Error (not JSON). The server’s terminal shows a long traceback that ends like this:
pydantic_core._pydantic_core.ValidationError: 1 validation error for Classificationpriority Input should be greater than or equal to 1 [type=greater_than_equal, input_value=0, input_type=int]Cause: your code made a Classification that breaks the model’s limits, here priority=0. Pydantic checks the values when you make the model, and refuses. This is a defect in the code, not a problem with the request, so the client gets 500. The model did its job: a wrong priority never reached a client.
Fix:
- Read the last lines of the traceback: the model, the field, the value and the rule.
- Find where your code makes that value, and correct it.
- Do not loosen the model to make the error go away. The limit is part of the promise to your clients.
Check your understanding
A client sends {"subject": ""}. Does the classify function run? What does the client receive?
Show the answer
The function does not run. FastAPI checks the body against TicketIn first, and finds that subject is shorter than 1 character. It answers 422 Unprocessable Content with an error whose loc is ["body", "subject"] and whose type is string_too_short. The function only ever sees valid tickets.
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.
Check your understanding
Why does the endpoint return a Classification model instead of a plain dictionary?
Show the answer
The model is a checked promise. Pydantic refuses a result that breaks it, such as an unknown category or a priority of 0, so a client never receives a wrong value. FastAPI also uses the model to document the response. With a dictionary, any value could reach the client.
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
- A Pydantic model lists the fields of the data, with types, limits and defaults.
- A model-typed parameter makes FastAPI read and check the JSON body before your function runs. Invalid input gets
422with every problem. - The return type is the response model: a checked promise about the output.
- Keep test bodies in files, and test both sides of every limit.
Next, you move the classifier out of main.py, and give each part of the API its own module.
Next: Separate concerns