Aabha AI Academy All articles

Before you build an API, write the request contract

Decide what a request must contain, what the server owns and what should happen when the input is unsuitable.

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.