APIs, schemas and validation
the boundary where everything is checked
What it is
An API is the set of addresses the world can talk to your server through. A schema is a description of what data comes in and goes out. Validation is the check that what arrived matches the schema.
For working with an AI this is the most important place in a backend: the boundary where data arrives from outside is the only place it can still be checked. Miss it here and wrong data spreads through the whole application.
What it looks like in code
from pydantic import BaseModel, Field
class SubIn(BaseModel):
city: str = Field(min_length=2, max_length=50)
class SubOut(BaseModel):
user_id: int
cities: list[str]
@app.post("/subs/{user_id}", response_model=SubOut, status_code=201)
def add_sub(user_id: int, body: SubIn) -> SubOut:
cities = SUBS.setdefault(user_id, [])
if body.city in cities:
raise HTTPException(status_code=409, detail="already subscribed")
cities.append(body.city)
return SubOut(user_id=user_id, cities=cities)What happens on invalid data:
curl -X POST http://127.0.0.1:8765/subs/7 \
-H "Content-Type: application/json" -d '{"city":"X"}'[422]
{"detail":[{"type":"string_too_short","loc":["body","city"],
"msg":"String should have at least 2 characters",
"input":"X","ctx":{"min_length":2}}]}The handler never ran at all. The check fired before it, and the response describes exactly what is wrong and where.
Why this is the same idea as types
Go back to article 6.3: mypy catches errors before the run. A schema does
the same thing at the server's boundary — it catches wrong data before the
handler.
The difference is where the boundary runs:
| Tool | What it checks | When |
|---|---|---|
mypy | your code agrees with itself | before running |
| The request schema | outside data matches expectations | before the handler |
| The response schema | you returned what you promised | before sending |
Three different frontiers, one idea: describe the shape of the data once and let a machine do the checking.
What goes in a schema
Not only types but constraints:
class SubIn(BaseModel):
city: str = Field(min_length=2, max_length=50)
notify_at: int = Field(ge=0, le=23) # an hour from 0 to 23
email: EmailStr | None = None # the format is checkedEvery constraint in a schema is a check you won't have to write by hand and the AI won't forget.
Addresses
Short rules that are enough:
| Rule | Example |
|---|---|
| Plural nouns | /subs, /users |
| The action is the method, not the address | DELETE /subs/7, not /deleteSub |
| Nesting through the path | /users/7/subs |
| Filters through the query string | /subs?city=Oslo&limit=10 |
What to tell the AI
Describe the request and response schemas for
POST /subs/{user_id}: city is a string of 2 to 50 characters,
the notification hour is a number from 0 to 23 and optional.
The response has user_id and the list of cities.
Don't write the handler code yet.A schema as a way of stating a task is the same as a signature in article 6.5: shorter than words and checked by a machine.
Check that no internal fields end up in response_model:
password hashes, service ids, internal flags. List what gets
sent to the client.Which constraints should this schema have so that malformed
data never reaches the handler? Constraints only, don't touch
the logic.What to look for
Validation inside the handler. if len(city) < 2: raise ... works, but
the checks smear across the code and some cases get forgotten. Checks belong
in the schema.
A dict instead of a schema. def add_sub(body: dict) switches
checking off entirely: anything at all can arrive.
No response_model. Whatever the handler returned gets sent.
A schema with no constraints. city: str with no length accepts both
an empty string and a megabyte-long one.
Different schemas for the same thing. Three slightly different descriptions of a subscription in three places will drift apart at the first edit.
Cheat sheet
The server's boundary is the only place outside data can still be checked.
The schema describes the shape, validation checks it, the framework does that for you.
On invalid data the handler never runs at all.
response_modelprotects against accidentally returning extra fields.Constraints (
min_length,ge,le) are checks that won't be forgotten.A schema in a prompt works like a signature: more precise than words.