FastAPI Basics Checkpoint
25 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Fix the mistakes
The slips that cost marks in this module. Spot each one, then check the fix.
This checkpoint builds a small product API from start to finish: routes, models, status codes, paging and a schema check. Each part uses what the three lessons taught.
Plan the routes before you write them
A small API is easier when you list its routes first: the method, the path, the success code and the failure codes. One noun in the path, one verb in the method. Then give each route an input model and an output model.
# POST /products 201 ProductIn -> ProductOut# GET /products 200 skip, limit# GET /products/{id} 200 or 404# DELETE /products/{id} 204 or 404This table is also your test plan. Every row needs at least one test for success and one for failure.
Validation answers and your own answers
Two different layers can say no. FastAPI says 422 when the shape of the request is wrong. Your code says 404 or 409 when the shape is fine but the business rule fails, for example a missing id.
r = client.post("/products", json={"name": "P", "price": 0})print(r.status_code)r = client.get("/products/99")print(r.status_code)Example 1 · A complete product API
from fastapi import FastAPI, HTTPExceptionfrom fastapi.testclient import TestClientfrom pydantic import BaseModel, Field
app = FastAPI(title="Product API")products = {}next_id = 1
class ProductIn(BaseModel): name: str = Field(min_length=2) price: float = Field(gt=0)
class ProductOut(ProductIn): id: int
@app.post("/products", response_model=ProductOut, status_code=201, summary="Add a product")def add_product(product: ProductIn): global next_id products[next_id] = product.model_dump() next_id += 1 return {"id": next_id - 1, **products[next_id - 1]}
@app.get("/products", response_model=list[ProductOut], summary="List products")def list_products(skip: int = 0, limit: int = 2): rows = [{"id": i, **p} for i, p in products.items()] return rows[skip:skip + limit]
@app.get("/products/{product_id}", response_model=ProductOut, summary="Read a product")def read_product(product_id: int): if product_id not in products: raise HTTPException(status_code=404, detail="Product not found") return {"id": product_id, **products[product_id]}
@app.delete("/products/{product_id}", status_code=204, summary="Delete a product")def delete_product(product_id: int): if products.pop(product_id, None) is None: raise HTTPException(status_code=404, detail="Product not found")
client = TestClient(app)
r = client.post("/products", json={"name": "Keyboard", "price": 49.5})print(r.status_code, r.json())client.post("/products", json={"name": "Monitor", "price": 180})client.post("/products", json={"name": "Router", "price": 60})print([p["name"] for p in client.get("/products").json()])print([p["name"] for p in client.get("/products?skip=2").json()])print(client.get("/products/2").json()["price"])print(client.delete("/products/2").status_code, client.get("/products/2").status_code)201 {'name': 'Keyboard', 'price': 49.5, 'id': 1}
['Keyboard', 'Monitor']
['Router']
180.0
204 404What this code does
- Input and output models are separate: the answer adds the
id. skipandlimitcut a page out of the list; the default page has 2 items.
The first call fails validation (422). The second passes validation but fails in the route (404). A good test checks both kinds.
Check the docs in a test
The schema is part of your API contract. A short test can read app.openapi() and confirm that the paths, methods and response codes are what you planned. If someone renames a route by accident, the test fails.
paths = app.openapi()["paths"]assert "post" in paths["/products"]assert "201" in paths["/products"]["post"]["responses"]Example 2 · Failures: 422 and 404
from fastapi import FastAPI, HTTPExceptionfrom fastapi.testclient import TestClientfrom pydantic import BaseModel, Field
app = FastAPI(title="Product API")products = {}next_id = 1
class ProductIn(BaseModel): name: str = Field(min_length=2) price: float = Field(gt=0)
class ProductOut(ProductIn): id: int
@app.post("/products", response_model=ProductOut, status_code=201, summary="Add a product")def add_product(product: ProductIn): global next_id products[next_id] = product.model_dump() next_id += 1 return {"id": next_id - 1, **products[next_id - 1]}
@app.get("/products", response_model=list[ProductOut], summary="List products")def list_products(skip: int = 0, limit: int = 2): rows = [{"id": i, **p} for i, p in products.items()] return rows[skip:skip + limit]
@app.get("/products/{product_id}", response_model=ProductOut, summary="Read a product")def read_product(product_id: int): if product_id not in products: raise HTTPException(status_code=404, detail="Product not found") return {"id": product_id, **products[product_id]}
@app.delete("/products/{product_id}", status_code=204, summary="Delete a product")def delete_product(product_id: int): if products.pop(product_id, None) is None: raise HTTPException(status_code=404, detail="Product not found")
client = TestClient(app)
for body in ({"name": "K", "price": 5}, {"name": "Mouse", "price": -2}, {"name": "Mouse"}): r = client.post("/products", json=body) print(r.status_code, [e["loc"][-1] for e in r.json()["detail"]])r = client.get("/products/7")print(r.status_code, r.json())print(client.get("/products?limit=abc").status_code)print(client.delete("/products/7").status_code)422 ['name']
422 ['price']
422 ['price']
404 {'detail': 'Product not found'}
422
404What this code does
- Each bad body gets a 422 that names the fields at fault.
- A missing id is a 404 raised by your own code.
Example 3 · The schema matches the plan
from fastapi import FastAPI, HTTPExceptionfrom fastapi.testclient import TestClientfrom pydantic import BaseModel, Field
app = FastAPI(title="Product API")products = {}next_id = 1
class ProductIn(BaseModel): name: str = Field(min_length=2) price: float = Field(gt=0)
class ProductOut(ProductIn): id: int
@app.post("/products", response_model=ProductOut, status_code=201, summary="Add a product")def add_product(product: ProductIn): global next_id products[next_id] = product.model_dump() next_id += 1 return {"id": next_id - 1, **products[next_id - 1]}
@app.get("/products", response_model=list[ProductOut], summary="List products")def list_products(skip: int = 0, limit: int = 2): rows = [{"id": i, **p} for i, p in products.items()] return rows[skip:skip + limit]
@app.get("/products/{product_id}", response_model=ProductOut, summary="Read a product")def read_product(product_id: int): if product_id not in products: raise HTTPException(status_code=404, detail="Product not found") return {"id": product_id, **products[product_id]}
@app.delete("/products/{product_id}", status_code=204, summary="Delete a product")def delete_product(product_id: int): if products.pop(product_id, None) is None: raise HTTPException(status_code=404, detail="Product not found")
client = TestClient(app)
paths = app.openapi()["paths"]for path, methods in paths.items(): for method, op in methods.items(): codes = sorted(op["responses"]) print(method.upper(), path, op["summary"], codes)print(sorted(app.openapi()["components"]["schemas"]["ProductOut"]["properties"]))POST /products Add a product ['201', '422']
GET /products List products ['200', '422']
GET /products/{product_id} Read a product ['200', '422']
DELETE /products/{product_id} Delete a product ['204', '422']
['id', 'name', 'price']What this code does
- Every planned route is in the schema with its summary and answer codes.
- The 404 is missing from the lists because it is raised in code; add
responses={404: ...}to document it.
This costs a few lines and no server. It also catches a missing status_code or a missing route.
Finish up
Mark complete & continue
Next: Routers and Layers