Skip to content

Request and response models

  • Lesson 5
  • 40 min
  • Module 2: Build a small FastAPI service

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 readyAbout 40 minutes

What you need to know

What you need on your computer

Tested with Python 3.14.6, FastAPI 0.142.2 and Pydantic 2.13 on macOS.

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 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: str

Literal[...] 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).

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:

ticket_api/main.py
from typing import Literal
from fastapi import FastAPI
from 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.

Amira keeps her test bodies as files in a folder requests, so each one is easy to send again. A valid ticket:

requests/billing.json
{"subject": "Charged twice for March", "body": "My card was charged two times for the same invoice."}
Terminal (on Windows: curl.exe)
curl -i -X POST http://127.0.0.1:8000/classify -H "Content-Type: application/json" --data @requests/billing.json
Output (first and last line)
HTTP/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:

Response body (422)
{"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”
  1. Replace ticket_api/main.py with the worked example, and save it. The development server restarts.

  2. Make the folder requests with the four files from the table: billing.json, empty-subject.json, no-subject.json and wrong-types.json.

  3. Send each file with curl, as in the worked example. Before each one, write down the status code that you expect.

    billing.json gives 200. The three others give 422, with the loc and msg from the table.

  4. 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.

  5. 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 422 with string_too_long. Then try exactly 120 characters: 200. You tested both sides of the limit.

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: Channel

In 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 Classification
priority
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:

  1. Read the last lines of the traceback: the model, the field, the value and the rule.
  2. Find where your code makes that value, and correct it.
  3. 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.

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.

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 422 with 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