Imagine an application that records shipments. Before writing the route, decide what a caller is allowed to send. A destination and parcel weight might belong in the request. The record's identifier and creation time may belong to the server. Writing those choices down gives you a contract to implement and review.
For each input, name the rule. Is it required? What kind of value does it accept? Are there meaningful limits? A weight should not become acceptable simply because it is a number: your application may require it to be greater than zero. The rule belongs in the contract and in the checks that exercise it.
Then describe failure. If the destination is missing, the application should give a useful response and avoid creating an incomplete record. If an identifier is server-owned, decide explicitly how the application handles a caller that sends one. Test that behaviour rather than assuming it follows from the field name.
FastAPI can use Pydantic models to describe and validate request bodies. That helps implement the boundary, but you still need to decide which business rules apply and what successful behaviour means. FastAPI request bodies
Two clients send "weight_g": 1250 and "weight_g": "1250". If the API converts the second value automatically, what can a passing test tell you about the caller's original JSON type? Choose a strict or converting policy for a first API, explain its effect on callers, and name a test that would expose a mismatch with your declared policy.
Automatic conversion can produce an integer without proving the caller sent one. A strict policy catches a caller's type mismatch; a converting policy may accommodate clients that send digits as strings. Either needs a declared rule and a test of the numeric-string request. In this example, the contract requires a whole-number weight and rejects the string. You will implement that choice and show that invalid requests leave the example's stored records unchanged. You need basic Python, package installation and terminal skills.
Write the contract in ordinary language
Our fictional shipment endpoint accepts POST /shipments. The caller supplies two fields:
| Field | Rule |
|---|---|
destination |
A required string. Remove surrounding whitespace; the result must contain 1–80 characters. |
weight_g |
A required JSON integer from 1 to 30,000, inclusive, measured in grams. Reject strings, booleans and decimal values. |
The server assigns id and created_at. This example rejects all extra request fields, including those two. A successful request returns 201 with the four public fields. Invalid request data returns 422 with validation details and creates no new record in the teaching store.
The size and weight limits are chosen for this exercise. They are not real shipping-service rules. Grams make the unit explicit and let this example use an integer; they do not establish how a real application should represent every measurement.
Turn those decisions into models
Create an isolated Python environment, activate it using the command appropriate for your shell, and install the versions used for this example:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install fastapi==0.141.1 pydantic==2.13.5 starlette==1.6.0 httpx==0.28.1 pytest==9.1.1
The activation line above is for a Unix shell. The example was checked with Python 3.12.13. Save the following complete application as main.py:
from datetime import datetime, timezone
from uuid import UUID, uuid4
from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict, Field
class ShipmentCreate(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
destination: str = Field(strict=True, min_length=1, max_length=80)
weight_g: int = Field(strict=True, gt=0, le=30_000)
class ShipmentRead(BaseModel):
id: UUID
created_at: datetime
destination: str
weight_g: int
def create_app() -> FastAPI:
app = FastAPI()
app.state.shipments = {}
@app.post("/shipments", status_code=201, response_model=ShipmentRead)
def create_shipment(payload: ShipmentCreate) -> ShipmentRead:
shipment = ShipmentRead(
id=uuid4(),
created_at=datetime.now(timezone.utc),
**payload.model_dump(),
)
app.state.shipments[str(shipment.id)] = shipment
return shipment
return app
app = create_app()
The request model is the caller's input. gt=0 and le=30_000 state the weight bounds; strict=True prevents this integer field from accepting a numeric string, a decimal value or a boolean through conversion. Field constraints belong beside the declared type. Pydantic fields
The configuration makes two further choices. It strips surrounding whitespace from strings, so a destination containing only spaces becomes empty and fails the length rule. It forbids extra fields. Pydantic's default extra-field policy is to ignore them, so rejection must be explicit here. Pydantic configuration
ShipmentRead describes the returned shape, including the fields assigned by the server. FastAPI uses response_model to validate and filter returned data. A response model does not decide who may create or view a shipment. Response models
The dictionary is a teaching store inside one application instance. It loses records when the process ends and does not demonstrate database transactions, concurrent writes or persistence.
Inspect success and deliberate failure
This input is accepted:
{"destination": " Kochi ", "weight_g": 1250}
The returned destination is "Kochi", with the same weight, a server-generated identifier and a UTC creation time. The values of the identifier and timestamp vary on each request.
Change one part of that input at a time:
| Variation | Result in this example | Reason |
|---|---|---|
Remove destination |
422 |
The field is required. |
Set destination to three spaces |
422 |
Normalization leaves an empty string. |
Set weight_g to 0 |
422 |
The weight must be positive. |
Set weight_g to 30001 |
422 |
It exceeds the exercise limit. |
Set weight_g to "1250" |
422 |
The contract requires an integer. |
Add id |
422 |
The caller may not supply this extra field. |
FastAPI's normal body-validation flow rejects unsuitable input before this route handler is called. A request can still trigger other application components such as dependencies or middleware; the no-write claim here concerns this handler's teaching store and is checked below.
Test the state as well as the status
Save this as test_main.py next to main.py:
from fastapi.testclient import TestClient
from main import create_app
def test_contract():
app = create_app()
with TestClient(app) as client:
accepted = client.post(
"/shipments", json={"destination": " Kochi ", "weight_g": 1250}
)
assert accepted.status_code == 201
body = accepted.json()
assert set(body) == {"id", "created_at", "destination", "weight_g"}
assert body["destination"] == "Kochi"
assert body["weight_g"] == 1250
assert set(app.state.shipments) == {body["id"]}
before = app.state.shipments.copy()
rejected = client.post(
"/shipments", json={"destination": "Kochi", "weight_g": "1250"}
)
assert rejected.status_code == 422
errors = rejected.json()["detail"]
assert ["body", "weight_g"] in [error["loc"] for error in errors]
assert app.state.shipments == before
Run python -m pytest -q. TestClient exercises the request and response without starting a network server. FastAPI testing
The test checks the successful response and stored identifier, then proves that a rejected request leaves the existing store intact. A status-code assertion alone would not establish that second behaviour. Checking the field location also confirms which input failed without depending on the exact wording of a library error message.
A teammate checks only that an invalid request returns 422. Imagine a bug that deletes an existing shipment and then returns that status. Explain why the test would pass, then add an assertion that would expose the bug. Extend the tests to the remaining rejected variations and the accepted boundaries 1 and 30000. Preserve an existing record before each rejected request so the state check can detect deletion as well as an unwanted insertion.
A useful rejection check compares the entire store with a snapshot taken before the request. Checking only the number of records could miss a deletion followed by an insertion. For an accepted boundary, require 201, the declared weight in the response and the new stored record. If a boundary returns 422, compare the implementation's inclusive bounds with the written contract.
Change the domain and keep the reasoning
Design POST /study-sessions with this contract: topic is a required string, trimmed to 1–80 characters; duration_minutes is a required strict JSON integer from 10 to 90 inclusive. Reject strings, booleans, decimal values and extra fields. The server assigns id and created_at; success returns 201 with these four public fields, and invalid input returns 422 without changing the store.
Before coding, write two accepted requests at the duration boundaries and four rejected requests that separately test an out-of-range duration, a numeric string, a blank-after-trimming topic and a caller-supplied identifier. Predict the response and state change for each. Implement the contract using the shipment example as a starting point, then test your predictions with an existing record in the store.
A complete solution accepts 10 and 90 and stores the trimmed topic. It rejects 9, "10", " " as the topic, and an extra id, with the existing store unchanged in every rejection. Use the new minutes unit and bounds; merely renaming weight_g would still accept durations below ten. If your implementation accepts the numeric string, check that strictness was carried into the new field. Passing these tests verifies the declared input boundary, not permission to create a session or persistent storage.
Finally, distinguish validation from business decisions. A destination string that passes these checks may still name an unsupported place. Whether a caller may create a shipment, whether a hub exists and whether a duplicate should be accepted require additional rules and evidence. The request model establishes one boundary; it does not implement the whole application.