6 REST APIs and FastAPI: Designing and Building Web Interfaces
6.1 Learning Objectives
By the end of this lecture you should be able to:
Explain what a web API is, why applications need one, and describe the client–server request/response model as a contract between systems.
Define REST as an architectural style, name its six constraints, and explain what each constraint buys you (and what ignoring it costs).
Model a domain as resources: design URIs for collections and items, and refactor an RPC-style API into a resource-oriented one.
Choose HTTP methods correctly using the safety and idempotency properties, including the PUT-vs-PATCH-vs-POST decision, and map CRUD operations onto them.
Distinguish path parameters from query parameters, and decide which one a given piece of request data should be.
Select appropriate status codes for common situations and design consistent, machine-readable error bodies.
Build and run a FastAPI application with uvicorn, and use the automatically generated interactive documentation at /docs.
Declare path parameters, query parameters, and request bodies in FastAPI using Python type hints, and predict how FastAPI validates each one.
Define Pydantic models for requests and responses, and explain what happens when a client sends data that does not match the model.
Use dependency injection (Depends) to share resources such as a data store across endpoints, and explain when async def endpoints help.
Write automated tests for an API using TestClient and pytest.
Trace, end to end, how a REST design decision becomes a line of FastAPI code.
6.2 Motivation: Design the Contract, Then Build It
6.2.1 Why applications need APIs
Almost every piece of modern software you interact with is, under the
hood, a conversation between programs. When you check your grades on
ELMS, a JavaScript front end in your browser asks a server for data.
When a mobile app shows you the campus shuttle’s location, it asks a
server. When your code calls an LLM—
This matters doubly in this course. Every AI-powered application you will build this semester has the same skeleton: a model (or a call to someone else’s model) wrapped in an API so that other software can use it. Learning to design and build APIs well is learning to ship software.
6.2.2 Design the API before you pick a framework
Later in this lecture you’ll build TerpTasks in FastAPI: path operations, Pydantic models, dependency injection, tests. FastAPI will make it easy to stand up endpoints fast. What it will not do is answer the design questions underneath those endpoints:
Should the endpoint be /tasks/42/complete or PATCH /tasks/42?
When a client creates a duplicate task, is that 400, 409, or 422?
Should “only my CMSC389A tasks” be part of the path or part of the query string?
When 40,000 tasks exist, what happens to GET /tasks?
FastAPI will happily serve any answer, good or bad—
6.2.3 Why REST won
REST (REpresentational State Transfer) comes from Roy Fielding’s 2000
PhD dissertation, which asked: why did the Web scale? HTML
pages, browsers, caches, and servers from different vendors, evolving
independently for decades, without coordinated upgrades—
The intuition to carry: REST is the Web’s physics applied to
your data. A REST API treats your tasks, users, and courses the way
the Web treats pages—
6.2.4 Why FastAPI, specifically
Python has many web frameworks (Flask, Django, Tornado, etc.).
FastAPI, first released in 2018, has become the default choice for
new Python API projects—
Type hints do triple duty. You declare the types of your inputs once, using ordinary Python type hints. FastAPI uses that single declaration to (1) parse and convert incoming data, (2) validate it and reject malformed requests automatically, and (3) generate interactive, always-up-to-date documentation.
It is fast to write and fast to run. It is built on Starlette (an async toolkit) and Pydantic (a validation library implemented in Rust), giving performance comparable to Node.js and Go frameworks.
It is async-native. Serving an LLM means waiting—
for the model, for a database, for another API. FastAPI’s async support lets one server process handle many simultaneous waiting requests efficiently.
A connection to earlier material: the central skill of this course is judgment over generated code. APIs are where that judgment pays off most, because an API is a boundary: type-checked, validated, documented, and testable. REST gives you the design vocabulary for that boundary; FastAPI makes it explicit and machine-checked, which makes AI-generated server code far easier to verify.
6.3 Running Example: TerpTasks
Throughout these notes we build one application, growing it as we introduce each concept: TerpTasks, a small service for tracking course assignments. A task looks like this:
{
"id": 1,
"title": "REST API assignment",
"course": "CMSC389A",
"due": "2026-09-21",
"priority": "medium",
"done": false
}Clients of TerpTasks will be able to:
list tasks, optionally filtered by course or completion status,
fetch a single task by its id,
create new tasks,
update tasks (e.g., mark them done), and
delete tasks.
TerpTasks is becoming a campus service: the CLI and web UI will both
talk to a hosted API, and other student projects want to integrate.
The intern (working without a design doc—
GET /getAllTasks |
GET /getTask?id=42 |
POST /addTask?title=Study&course=CMSC389A&due=2026-07-10 |
POST /updateTask (body: anything, returns 200 "OK" always) |
GET /deleteTask?id=42 |
POST /markTaskAsDone?id=42 |
GET /getTasksPage2 |
It works—
6.4 API Fundamentals
A web API (Application Programming Interface) is a contract, exposed over HTTP, that specifies a set of endpoints (URLs), the methods that may be applied to them (GET, POST, PUT, DELETE, etc.), the shape of the data each accepts, and the shape of the data each returns.
In plain English: an API is a menu. It tells client programs
exactly what they can order (GET /tasks —
Intuition. A web server that serves an API is like a
restaurant with a strict menu and a very literal-minded kitchen. If
you order something on the menu in the correct format, you get a
predictable dish. If you order something that isn’t on the menu, you
don’t get a shrug—
Common misconception. Students often conflate a web API with a website. A website returns HTML for humans to read; an API returns structured data (almost always JSON) for programs to consume. FastAPI can serve HTML, but its purpose is the latter.
6.4.1 Client, server, request, response
Every API interaction is a round trip between two independent programs:
Client Server |
| | |
|---------------- API Request -------------->| |
| | |
|<--------------- API Response ---------------| |
| | |
The client (a browser, a mobile app, a test script, another server) initiates every exchange; the server only ever responds. This is REST’s first constraint, client–server separation: the client owns the user interface and user state, the server owns the data and business logic, and each can change independently as long as the contract between them holds. A redesign of TerpTasks’ web UI shouldn’t require touching the server, and a migration of the server’s database shouldn’t require touching every client.
6.4.2 An API is a contract
Because the client and server are built (and often maintained) by
different people, at different times, the API is the only thing they
share: a contract. “Send a POST to /tasks with a
title and a course field, and you will get back a 201
with the created task” is a promise every client can rely on without
reading the server’s source code—
6.5 HTTP Fundamentals
REST APIs are built on top of HTTP (HyperText Transfer Protocol), the same protocol your browser uses to fetch web pages. An HTTP request consists of a method, a URL, some headers, and an optional body:
HTTP Request |
├── Method (GET, POST, PUT, PATCH, DELETE, ...) |
├── URL (/tasks, /tasks/42, ...) |
├── Headers (Content-Type, Authorization, ...) |
└── Body (JSON payload, present on POST/PUT/PATCH) |
An HTTP response consists of a status code, headers, and a body:
HTTP Response |
├── Status Code (200, 201, 404, 422, ...) |
├── Headers (Content-Type, Location, ...) |
└── Body (JSON payload, or empty) |
For example, a client asking TerpTasks for all tasks:
GET /tasks |
might receive:
{
"tasks": [
{
"id": 1,
"title": "REST API assignment",
"course": "CMSC389A",
"done": false
}
]
}The methods have conventional meanings. Respecting these conventions
is what makes your API predictable to other developers (and to AI
coding assistants, which have internalized the conventions from
millions of examples)—
6.6 HTTP Methods and CRUD
Most APIs are, underneath, doing one of four things to data: Create, Read, Update, Delete. HTTP gives each of these a conventional method:
Operation | HTTP Method |
Create | POST |
Read | GET |
Update | PUT / PATCH |
Delete | DELETE |
Applied to TerpTasks’ /tasks resource:
GET /tasks list all tasks |
GET /tasks/123 read task 123 |
POST /tasks create a task |
PUT /tasks/123 replace task 123 entirely |
PATCH /tasks/123 update part of task 123 |
DELETE /tasks/123 delete task 123 |
Each HTTP method also carries two contractual properties. A method is safe if it must not change server state (pure read). A method is idempotent if doing it N times has the same effect as doing it once.
Method | Meaning on /tasks... | Safe | Idempotent | Typical success |
GET | read collection or item | yes | yes | 200 |
POST | create in collection / non-idempotent ops | no | no | 201 + Location |
PUT | replace item entirely | no | yes | 200 (or 204) |
PATCH | modify part of item | no | no* | 200 |
DELETE | remove item | no | yes | 204 |
(*A JSON-merge PATCH like {"done": true} happens to be idempotent; PATCH in general doesn’t promise it.)
Intuition. Safety and idempotency aren’t etiquette—
Why is PUT idempotent but POST not? Trace it.
PUT /tasks/42 {"title":"Study", "done":false} sent three times:
the final state is identical after each—
PUT vs PATCH, concretely. Start: {"title": "Study", "course": "CMSC389A", "done": false}.
PATCH /tasks/42 with {"done": true} → title and course survive: {"title": "Study", "course": "CMSC389A", "done": true}.
PUT /tasks/42 with {"done": true} → you asked to replace the resource with a document that has no title and no course. A strict server rejects it (422, missing fields); a lenient one nulls the other fields. Either way, PUT-with-a-fragment is the bug.
Misconception. “POST is for create, PUT is for
update.” Close but off-axis. PUT is replace at a known
URI—
6.7 REST Resource Design
6.7.1 What is REST, formally?
REST is an architectural style: a
named set of constraints on how a distributed system is organized.
An API is “RESTful” to the degree it satisfies them—
In plain English: model your domain as things with addresses (resources), manipulate them with HTTP’s built-in verbs, tell the truth in status codes and headers, and never make the server remember who a client is between requests.
Common misconceptions.
“REST = JSON over HTTP.” JSON is just today’s popular representation. An API returning XML can be RESTful; an API returning JSON from POST /doStuff is not.
“REST is a standard/protocol.” It’s a style. There is no REST RFC; there are HTTP RFCs, which REST uses well.
“Stateless means the server stores no data.” The server stores plenty of resource state (your tasks). What it must not store is session state—
“this connection is logged in as Alice and is on page 2.” “Any HTTP API is a REST API.” The intern’s API from Running Example: TerpTasks is HTTP and violates nearly every constraint.
6.7.2 Resources and URIs
A resource is any named thing your
API exposes—
In plain English: design the nouns first. URLs name things; the verbs come from HTTP.
The standard shape, applied to both of TerpTasks’ resources:
/tasks the collection of tasks |
/tasks/42 one task |
/courses the collection of courses |
/courses/CMSC389A one course |
Conventions that make an API feel professional (all are conventions,
not laws—
Worked example—
Intern’s endpoint | Sin | RESTful replacement |
GET /getAllTasks | verb in URL | GET /tasks |
GET /getTask?id=42 | verb; identity in query | GET /tasks/42 |
POST /addTask?title=... | data in query string | POST /tasks (JSON body) |
POST /updateTask | which task? whole or part? | PUT / PATCH /tasks/42 |
GET /deleteTask?id=42 | GET with side effects(!) | DELETE /tasks/42 |
POST /markTaskAsDone?id=42 | action-as-endpoint | PATCH /tasks/42 {"done": true} |
GET /getTasksPage2 | page baked into the name | GET /tasks?offset=20&limit=20 |
Misconception. “Deep nesting shows good structure.” /courses/CMSC389A/students/7/tasks/42 forces clients to know three identifiers to fetch one task. Nest one level for true ownership; otherwise give the resource its own top-level home.
6.7.3 The six constraints and the Richardson Maturity Model
REST is defined by six constraints on how a distributed system is organized; an API is “RESTful” to the degree it satisfies them:
Client–server—
UI concerns and data concerns are separated and evolve independently. Stateless—
every request contains everything needed to process it; the server keeps no session memory between requests. Cacheable—
responses declare whether (and how long) they may be reused. Uniform interface—
resources are identified by URIs; they’re manipulated through representations; messages are self-descriptive; a small universal verb set applies to everything. Layered system—
clients can’t tell (and needn’t care) whether they’re talking to the origin server, a cache, or a proxy. Code-on-demand (optional)—
servers may ship executable code to clients (this is how browsers get JavaScript; APIs rarely use it).
A useful ruler: the Richardson Maturity Model. Level 0: one
URL, one verb, RPC in a body. Level 1: many URLs (resources) but
verbs ignored. Level 2: resources + correct verbs + correct status
codes—
Worked example. Score the intern’s API: everything is a
GET/POST on verb-named URLs (/getTask,
/deleteTask)—
Statelessness, worked. Stateful design: POST /login sets a server-side session “Alice, page 2”; later GET /nextPage means whatever the server remembers. Now scale to two servers behind a load balancer: request 2 lands on the server that never met Alice. Stateless design: every request carries Authorization: Bearer <token> and full parameters (GET /tasks?offset=20&limit=20). Any server can handle any request; crash one, nobody notices.
6.8 Path Parameters vs Query Parameters
Every REST design eventually asks the same question: does this piece of information belong in the path or in the query string? Getting this right is what separates a clean API from an inconsistent one.
Path parameters identify which resource you mean:
GET /tasks/123 |
Here 123 is not optional and not a filter—
Query parameters modify how or how much of a request: filtering, searching, sorting, pagination.
GET /tasks?course=CMSC389A |
GET /tasks?course=CMSC389A&done=false |
GET /tasks?course=CMSC389A&done=false is appropriate here because
we’re still asking for the tasks collection—
Intuition. If removing the value changes which thing you are talking about, it is a path parameter; if it changes how or how much, it is a query parameter.
An alternative worth knowing. You could instead write:
GET /courses/CMSC389A/tasks |
This can make sense when the API models courses and their tasks as a
strong resource relationship—
6.9 HTTP Status Codes
The status code is the machine-readable verdict of a request, in three families you’ll use constantly: 2xx success, 4xx the client is wrong, 5xx the server is wrong.
The ones you’ll actually use for TerpTasks (deliberately not every code HTTP defines):
Code | Name | Use when |
200 | OK | generic success with a body |
201 | Created | resource created -- include Location: /tasks/43 |
204 | No Content | success, nothing to say (DELETE, sometimes PUT) |
400 | Bad Request | malformed syntax (unparseable JSON) |
401 | Unauthorized | who are you? (missing/invalid credentials) |
403 | Forbidden | I know who you are; you may not do this |
404 | Not Found | no such resource (also: hiding things from the unauthorized) |
409 | Conflict | valid request, conflicting state (duplicate, stale version) |
422 | Unprocessable Entity | parseable but semantically invalid (due date in the past) |
500 | Internal Server Error | you crashed; never the client's fault |
Applied to TerpTasks: successfully retrieving tasks → 200; successfully creating a task → 201; a task that doesn’t exist → 404; invalid request data → the appropriate 4xx.
401 vs 403 vs 404, worked. Request: DELETE /tasks/42. No
token → 401 (authenticate first). Valid token, but task 42
belongs to another user → policy choice: 403 admits the task
exists; 404 hides it (GitHub does this for private
repos—
Intuition. The status code is for code; the body is
for humans and logs. Clients branch on the number (surface
422 to the user, page someone on 500). An API that returns
200 {"error": "not found"}—
Error bodies: be boringly consistent. Pick one shape and use it for every error:
{
"error": {
"code": "task_due_in_past",
"message": "Field 'due' must be today or later.",
"field": "due"
}
}A stable machine-readable code, a human message, and a
pointer to the offending input. FastAPI’s automatic 422 for
Pydantic failures, which you’ll meet shortly, follows this
spirit—
6.10 REST API Design Exercise
Before writing any Python, design the API. TerpTasks needs endpoints to:
list all tasks,
get one task,
get incomplete tasks,
get tasks for CMSC389A,
create a task,
update a task, and
delete a task.
Sketch each endpoint—
GET /tasks |
GET /tasks/{id} |
GET /tasks?done=false |
GET /tasks?course=CMSC389A |
GET /tasks?course=CMSC389A&done=false |
POST /tasks |
PUT /tasks/{id} |
DELETE /tasks/{id} |
Discussion questions. For each line above: why is {id} a
path parameter but course and done query
parameters? What would break if id were a query parameter
instead (GET /tasks?id=42)? If you wanted to support “update
just the done field,” would you add a query parameter to
PUT, change the method to PATCH, or add a new endpoint—
6.11 REST API code example
The smallest REST API that is still REST: read-only tasks, standard
library only—
import json
import re
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse
tasks = [
{"id": 1, "title": "Study for 389A", "due": "2026-07-10", "done": False},
{"id": 2, "title": "Read Fielding's dissertation", "due": "2026-07-12", "done": False},
]
TASK_URI = re.compile(r"^/tasks/(\d+)$")
class TaskHandler(BaseHTTPRequestHandler):
def _send_json(self, status, body):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(body).encode("utf-8"))
def do_GET(self):
path = urlparse(self.path).path
if path == "/tasks":
# Collection GET: an envelope, never a bare array.
self._send_json(200, {"items": tasks, "total": len(tasks)})
return
match = TASK_URI.match(path)
if match:
task = next((t for t in tasks if t["id"] == int(match.group(1))), None)
if task is not None:
self._send_json(200, task)
return
# One error shape for the whole API (see lecture: be boringly consistent).
self._send_json(404, {"error": {"code": "not_found", "message": "no such resource"}})
if __name__ == "__main__":
HTTPServer(("", 8000), TaskHandler).serve_forever()How to run.
python3 server.py & # :8000; fg + Ctrl-C to stop
curl http://localhost:8000/tasks # 200, envelope with items + total
curl http://localhost:8000/tasks/1 # 200, a single task
curl http://localhost:8000/tasks/99 # 404, the one error shape6.12 Transition from REST to FastAPI
REST describes how we design and interact with an API. FastAPI is a Python framework that helps us implement that design.
REST API |
| |
API Design |
| |
HTTP + Resources + Methods |
| |
FastAPI |
| |
Python Implementation |
Everything from here forward assumes the design work above is
done—
You’ll meet this pattern constantly beyond this course: GitHub’s API
(which your Claude Code agent calls through the gh CLI and MCP
servers), Stripe, Canvas, campus data services. A large fraction of
the “tools” an AI agent calls are thin wrappers over REST APIs
exactly like the one you’re about to build—
6.13 FastAPI Introduction
What FastAPI gives you, concretely: routing from decorators,
automatic parsing and validation of path/query/body data from Python
type hints, JSON serialization of your return values, and
interactive documentation generated from the same type hints—
Install the framework and a server into a virtual environment:
python3 -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]"Here is the smallest possible TerpTasks—
from fastapi import FastAPI
# The application object. Everything in your API hangs off of it.
app = FastAPI(title="TerpTasks")
@app.get("/")
def root():
# Returning a dict => FastAPI serializes it to JSON for you.
return {"message": "Hello CMSC389A"}Run it with uvicorn, the server that actually listens on a network port and forwards requests to your app object:
uvicorn main:app --reloadINFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) |
INFO: Application startup complete. |
The main:app syntax means “in module main, find the
variable app.” The –reload flag restarts the server when
you edit a file—
curl http://127.0.0.1:8000/{"message": "Hello CMSC389A"}A path operation is the association of an HTTP method and a path template with a Python function (the path operation function, or informally the endpoint). In FastAPI it is created with a decorator: @app.get("/"), @app.post("/tasks"), and so on.
In plain English: the decorator registers your function in a routing table. When a request arrives, FastAPI looks up (method, path) in the table and calls your function.
Intuition. Think of app as a switchboard operator with a binder. The decorators write entries into the binder; at runtime the operator just looks up who should take each call.
The decorator’s argument is the path, not a full URL. The host and port belong to the server, not your code.
The function name (root) is irrelevant to routing; it only matters for documentation and readability. Two functions may not, however, register the same method and path—
the first match wins and the second is silently shadowed.
With the server running, open http://127.0.0.1:8000/docs to see
FastAPI’s generated documentation—
6.14 FastAPI Routes
REST API Design Exercise’s design maps directly onto FastAPI decorators:
REST Design | FastAPI |
GET /tasks | @app.get("/tasks") |
GET /tasks/{id} | @app.get("/tasks/{task_id}") |
POST /tasks | @app.post("/tasks") |
PATCH /tasks/{id} | @app.patch("/tasks/{task_id}") |
DELETE /tasks/{id} | @app.delete("/tasks/{task_id}") |
The pattern repeats throughout this lecture: REST design tells
us what the endpoint should be; FastAPI gives us the Python syntax to
implement it. Nothing about which method or path is a FastAPI
decision—
6.15 Path Parameters in FastAPI
TerpTasks needs “fetch task number 7.” The id varies per request, so it becomes a path parameter:
@app.get("/tasks/{task_id}")
def read_task(task_id: int):
return {"task_id": task_id, "title": "(stub)"}A path parameter is a variable segment of the path template, written in curly braces, bound to a function argument of the same name. The argument’s type hint determines parsing and validation.
In plain English: {task_id} is a blank in the URL. FastAPI fills in the blank with whatever the client put there, converted to the declared type.
The type hint is doing real work. Request /tasks/7 and your
function receives the integer 7, not the string "7".
Request /tasks/banana and your function is never called at
all—
curl http://127.0.0.1:8000/tasks/banana{
"detail": [
{
"type": "int_parsing",
"loc": ["path", "task_id"],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "banana"
}
]
}Intuition. In frameworks without typed parsing, everything arriving from the network is a string, and every endpoint begins with defensive boilerplate: convert, check, return an error. FastAPI inverts this: you declare what you expect, and only data meeting the expectation reaches your code.
Route order matters for overlapping paths. If you also have GET /tasks/count, it must be declared before GET /tasks/{task_id}; otherwise FastAPI tries to parse "count" as an int and returns 422. Routes are matched in declaration order.
Path parameters are always required. There is no such thing as an optional path parameter; if a value may be absent, it should be a query parameter instead—
exactly the distinction from Path Parameters vs Query Parameters.
6.16 Query Parameters in FastAPI
Path Parameters vs Query Parameters’s GET /tasks?course=CMSC389A&done=false becomes:
@app.get("/tasks")
def list_tasks(course: str | None = None, done: bool | None = None):
# `course` and `done` both come from the query string.
# Defaults make them optional.
return {"filters": {"course": course, "done": done}}A query parameter is a key–value pair in the URL’s query string, bound to a function argument by name. A parameter with a default value is optional; one without a default is required, and FastAPI returns 422 if it is missing.
Intuition. Path parameters identify a resource (“task 7”); query parameters modify a request about resources (“...but only the unfinished ones from CMSC389A”).
Note the conversions: ?done=false arrives as the Python boolean False (FastAPI accepts true/false, 1/0, yes/no). Again, ill-typed input (?done=lots) never reaches your code.
Misconception. Students often expect ?done=false to
arrive as the string "false"—
6.17 Request Bodies and Pydantic
Creating a task requires the client to send structured data—
A Pydantic model is a class inheriting from pydantic.BaseModel whose class-level type annotations define a data schema. Instantiating the model from external data triggers validation: each field is checked (and where sensible, converted) against its annotation, and a structured error is produced on failure.
What a client must send to create a TerpTasks task:
from datetime import date
from enum import Enum
from pydantic import BaseModel, Field
class Priority(str, Enum):
low = "low"
medium = "medium"
high = "high"
class TaskCreate(BaseModel):
"""What a client sends to create a task."""
title: str = Field(min_length=1, max_length=120)
course: str = Field(min_length=1, max_length=20)
due: date | None = None # optional; "YYYY-MM-DD" if present
priority: Priority = Priority.medium # optional with a defaultTo accept it, declare a parameter of that type. A parameter whose type is a Pydantic model is read from the request body:
@app.post("/tasks", status_code=201)
def create_task(data: TaskCreate):
# If we get here, `data` is guaranteed valid:
# non-empty title, real date, priority in the enum.
return {"created": data.title, "priority": data.priority}In plain English: the model is a bouncer at the door. Your function never sees a task with an empty title, a malformed date, or a priority of "super-urgent".
A well-formed request:
curl -X POST http://127.0.0.1:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Read ch. 3", "course": "CMSC389A", "due": "2026-09-21"}'{"created": "Read ch. 3", "priority": "medium"}Note the two conversions FastAPI performed silently:
"2026-09-21" became a datetime.date, and the omitted
priority took its default. Now a malformed one—
curl -X POST http://127.0.0.1:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "", "course": "CMSC389A", "due": "someday"}'{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "title"],
"msg": "String should have at least 1 character",
"input": ""
},
{
"type": "date_from_datetime_parsing",
"loc": ["body", "due"],
"msg": "Input should be a valid date or datetime, invalid character in year",
"input": "someday"
}
]
}The response is a 422 listing every problem with a
machine-readable location (loc)—
“Validation happens when I access the field.” No: validation happens once, at the boundary, before your function runs. Inside the function the data is plain, trustworthy Python objects.
“I should validate again inside the function.” Re-checking what the model already guarantees is noise. (Checks the schema cannot express—
“the due date must be during the semester”— do belong in your code, or in a Pydantic custom validator.) “One model fits all uses.” Notice we named the model TaskCreate, not Task. What a client sends (no id, no done) differs from what the server stores and returns (both present). Using separate models per direction is the single most important schema-design habit in FastAPI; conflating them leads to clients being able to set fields they shouldn’t (such as id).
6.18 Responses: Models, Status Codes, and Errors
6.18.1 Response models
Just as TaskCreate controls what comes in, a model can control what goes out. The full task, as stored:
class Task(TaskCreate):
"""A stored task: everything the client sent, plus server fields."""
id: int
done: bool = False@app.post("/tasks", response_model=Task, status_code=201)
def create_task(data: TaskCreate):
task = store.add(data) # store assigns the id (next section)
return taskThe response_model argument declares the schema of the response body. FastAPI filters the returned value through it: fields not in the model are stripped, and the model appears in the generated documentation.
Why filtering matters. Suppose Task later grows an internal field, say owner_email. If an endpoint’s response_model is a public model without that field, the email can never leak, even if a future refactor (or an AI assistant) returns the full object. The declaration is a guardrail, not a comment.
6.18.2 Errors with HTTPException
What should GET /tasks/999 do when there is no task 999? Raise:
from fastapi import HTTPException
@app.get("/tasks/{task_id}", response_model=Task)
def read_task(task_id: int):
task = store.get(task_id)
if task is None:
raise HTTPException(status_code=404,
detail=f"No task with id {task_id}")
return taskcurl -i http://127.0.0.1:8000/tasks/999HTTP/1.1 404 Not Found |
content-type: application/json |
|
{"detail":"No task with id 999"} |
In plain English: HTTPException is a controlled ejection
seat. Raising it anywhere in the request’s call stack aborts
processing and sends a clean error response, in the spirit of
HTTP Status Codes. Contrast this with any other uncaught
exception, which produces a 500 and a stack trace in your server
log—
Common misconception. Returning an HTTPException (instead of raising it) does not work; FastAPI would try to serialize the exception object as the response body. It is control flow, so it must be raised.
6.19 Swagger UI and OpenAPI
FastAPI |
| |
OpenAPI specification |
| |
Swagger UI |
FastAPI generates a machine-readable description of your entire
API—
This is not a gimmick. The docs are generated from your type hints and Pydantic models, so they cannot drift out of date the way hand-written docs do. Open /docs for TerpTasks and try GET /tasks, GET /tasks/{task_id}, POST /tasks, PATCH /tasks/{task_id}, and DELETE /tasks/{task_id} directly from the page.
Common misconception. Swagger UI is not the REST API
itself—
6.20 Dependency Injection with Depends
So far “the store” has been hand-waved. The obvious move is a global variable, and for a toy that works. But globals make testing painful (tests share state) and hide what each endpoint needs. FastAPI’s answer is its dependency injection system.
A dependency is a callable that FastAPI invokes before your endpoint, whose return value is passed in as an argument. You declare it with Depends(callable) as the parameter’s default value.
from fastapi import Depends
# storage.py -----------------------------------------------------------
class TaskStore:
"""An in-memory 'database': a dict of id -> Task."""
def __init__(self):
self._tasks: dict[int, Task] = {}
self._next_id = 1
def add(self, data: TaskCreate) -> Task:
task = Task(id=self._next_id, **data.model_dump())
self._tasks[task.id] = task
self._next_id += 1
return task
def get(self, task_id: int) -> Task | None:
return self._tasks.get(task_id)
store = TaskStore() # one shared instance for the whole app
def get_store() -> TaskStore:
return store
# main.py --------------------------------------------------------------
@app.get("/tasks/{task_id}", response_model=Task)
def read_task(task_id: int, store: TaskStore = Depends(get_store)):
task = store.get(task_id)
if task is None:
raise HTTPException(status_code=404,
detail=f"No task with id {task_id}")
return taskIn plain English: instead of the endpoint reaching out for the store, the store is handed to the endpoint. The endpoint declares what it needs; the framework supplies it.
Intuition. Think of a chef who lists ingredients rather than
shopping mid-recipe. Because the ingredients are listed, you can
substitute them: in tests, we will hand the endpoint a fresh
store instead of the shared one—
Depends(get_store) passes the function, not its result—
no parentheses after get_store. Writing Depends(get_store()) calls it too early and defeats overriding. Dependencies can do more than fetch globals: they can read headers (authentication!), open and close database sessions (yield dependencies), or depend on other dependencies. We only scratch the surface here.
6.21 Build a Complete CRUD API
Time to assemble every piece into the full TerpTasks API, built up incrementally rather than all at once.
6.21.1 Shapes: models.py
One model per direction of data flow, as Request Bodies and Pydantic
argued—
"""Pydantic schemas for TerpTasks.
Three models, one per direction of data flow:
TaskCreate -- what clients send to create a task
TaskUpdate -- what clients send to modify one (all fields optional)
Task -- what the server stores and returns
"""
from datetime import date
from enum import Enum
from pydantic import BaseModel, Field
class Priority(str, Enum):
low = "low"
medium = "medium"
high = "high"
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=120,
examples=["Finish FastAPI reading"])
course: str = Field(min_length=1, max_length=20, examples=["CMSC389A"])
due: date | None = None
priority: Priority = Priority.medium
class TaskUpdate(BaseModel):
"""All fields optional: clients send only what they want to change."""
title: str | None = Field(default=None, min_length=1, max_length=120)
course: str | None = Field(default=None, min_length=1, max_length=20)
due: date | None = None
priority: Priority | None = None
done: bool | None = None
class Task(TaskCreate):
"""Server-side representation: client fields plus server-owned fields."""
id: int
done: bool = False6.21.2 State: storage.py
Deliberately database-shaped: add/get/list/update/delete. Replacing
this class with one backed by SQLite or Postgres would not change
main.py at all—
from .models import Task, TaskCreate, TaskUpdate
class TaskStore:
def __init__(self) -> None:
self._tasks: dict[int, Task] = {}
self._next_id = 1
def add(self, data: TaskCreate) -> Task:
task = Task(id=self._next_id, **data.model_dump())
self._tasks[task.id] = task
self._next_id += 1
return task
def get(self, task_id: int) -> Task | None:
return self._tasks.get(task_id)
def list(self, course: str | None = None,
done: bool | None = None) -> list[Task]:
tasks = list(self._tasks.values())
if course is not None:
tasks = [t for t in tasks if t.course == course]
if done is not None:
tasks = [t for t in tasks if t.done == done]
return tasks
def update(self, task_id: int, changes: TaskUpdate) -> Task | None:
task = self._tasks.get(task_id)
if task is None:
return None
# Only apply fields the client actually sent.
updated = task.model_copy(
update=changes.model_dump(exclude_unset=True))
self._tasks[task_id] = updated
return updated
def delete(self, task_id: int) -> bool:
return self._tasks.pop(task_id, None) is not None
store = TaskStore()
def get_store() -> TaskStore:
"""Dependency: hands endpoints the shared store (tests override this)."""
return storeOne subtlety worth pausing on: exclude_unset=True in
update. A PATCH body of {"done": true} should change
only done. Without exclude_unset, the unsent fields
would be “applied” as their defaults (title=None...),
clobbering real data. This is exactly the kind of subtle bug to
watch for in AI-generated update endpoints—
6.21.3 The boundary: main.py
Thin by design: if an endpoint function grows past a dozen lines, logic is usually trying to escape into a module of its own.
"""TerpTasks: a task-tracking API for course assignments."""
from fastapi import Depends, FastAPI, HTTPException
from .models import Task, TaskCreate, TaskUpdate
from .storage import TaskStore, get_store
app = FastAPI(
title="TerpTasks",
description="Track assignments across your courses.",
version="1.0.0",
)
@app.get("/")
def read_root():
return {"message": "Welcome to TerpTasks! See /docs for the API."}
@app.get("/tasks", response_model=list[Task])
def list_tasks(course: str | None = None, done: bool | None = None,
store: TaskStore = Depends(get_store)):
return store.list(course=course, done=done)
@app.post("/tasks", response_model=Task, status_code=201)
def create_task(data: TaskCreate,
store: TaskStore = Depends(get_store)):
return store.add(data)
@app.get("/tasks/{task_id}", response_model=Task)
def read_task(task_id: int, store: TaskStore = Depends(get_store)):
task = store.get(task_id)
if task is None:
raise HTTPException(404, detail=f"No task with id {task_id}")
return task
@app.patch("/tasks/{task_id}", response_model=Task)
def update_task(task_id: int, changes: TaskUpdate,
store: TaskStore = Depends(get_store)):
task = store.update(task_id, changes)
if task is None:
raise HTTPException(404, detail=f"No task with id {task_id}")
return task
@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int, store: TaskStore = Depends(get_store)):
if not store.delete(task_id):
raise HTTPException(404, detail=f"No task with id {task_id}")A note on PUT vs. PATCH here. TerpTasks implements
update as PATCH, because “mark this task done” should
touch exactly one field—
6.21.4 Running and testing the project
git clone <your-repo-url> terptasks && cd terptasks
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload # serve at http://127.0.0.1:8000
pytest -q # run the test suiteA complete session against the running server:
curl -s -X POST localhost:8000/tasks -H 'Content-Type: application/json' \
-d '{"title":"FastAPI notes","course":"CMSC389A","priority":"high"}'
# => {"title":"FastAPI notes","course":"CMSC389A","due":null,
# "priority":"high","id":1,"done":false}
curl -s -X PATCH localhost:8000/tasks/1 -H 'Content-Type: application/json' \
-d '{"done": true}'
# => {... "done":true} (only `done` changed)
curl -s 'localhost:8000/tasks?course=CMSC389A&done=true'
# => [{...task 1...}]
curl -s -X DELETE localhost:8000/tasks/1 -i | head -1
# => HTTP/1.1 204 No Content6.22 async def: When It Matters
You will see both of these in the wild, and FastAPI accepts both:
@app.get("/sync")
def handler_a():
...
@app.get("/async")
async def handler_b():
...An async def endpoint runs as a coroutine on the server’s event loop; it may await other coroutines, yielding control while waiting. A plain def endpoint is run in a thread pool so it cannot block the loop.
Intuition. An async server is a barista who starts your espresso shot and, while the machine runs, takes the next order. await marks the moments the barista is free to do other work. A synchronous handler is a barista who stares at the machine until the shot finishes.
The rule of thumb:
If your handler calls async-capable I/O (an async HTTP client like httpx calling an LLM API, an async database driver), use async def and await—
one process can then juggle hundreds of in-flight requests. If your handler only does quick in-memory work (like TerpTasks), or uses blocking libraries (requests, time.sleep, classic database drivers), use plain def.
The misconception that bites hardest: writing async def
and then calling blocking code inside it. That blocks the
entire event loop—
A realistic preview of where you will want async later in the
course—
import httpx
@app.post("/summarize")
async def summarize(doc: DocumentIn):
async with httpx.AsyncClient() as client:
r = await client.post(LLM_API_URL, json=make_prompt(doc))
return {"summary": r.json()["content"]}While one request awaits the model (often seconds!), the server keeps handling others.
6.23 Testing FastAPI Applications
An untested API is a rumor. FastAPI ships a TestClient that
calls your app in-process—
from fastapi.testclient import TestClient
from app.main import app
from app.storage import TaskStore, get_store
import pytest
@pytest.fixture()
def client():
# Hand every test a FRESH store via dependency override,
# so tests cannot leak state into each other.
fresh = TaskStore()
app.dependency_overrides[get_store] = lambda: fresh
yield TestClient(app)
app.dependency_overrides.clear()
def test_create_then_fetch(client):
r = client.post("/tasks", json={"title": "Study", "course": "CMSC389A"})
assert r.status_code == 201
task_id = r.json()["id"]
r = client.get(f"/tasks/{task_id}")
assert r.status_code == 200
assert r.json()["title"] == "Study"
assert r.json()["done"] is False
def test_missing_task_is_404(client):
assert client.get("/tasks/999").status_code == 404
def test_validation_rejects_empty_title(client):
r = client.post("/tasks", json={"title": "", "course": "CMSC389A"})
assert r.status_code == 422Walk through test_create_then_fetch step by step:
The fixture builds a brand-new TaskStore and tells the app: “whenever an endpoint asks for get_store, give it this one instead.” This is dependency injection (Dependency Injection with Depends) paying rent.
client.post(..., json=...) serializes the dict, sends it through the full FastAPI stack—
routing, validation, serialization— and returns a response object. This is an integration test of the real request path. We assert on the status code first (the contract from HTTP Status Codes), then on the body. Note we read the id from the create response and use it in the fetch—
tests should not assume ids start at 1.
pytest -q... [100%] |
3 passed in 0.21s |
Practical implication for this course. When you ask an AI assistant to extend your API, these tests are your verification harness. A green test suite after an AI-written change is evidence; “it looks right” is not. Get in the habit of asking for the tests first, reviewing them, and only then asking for the implementation.
6.24 Connecting REST Concepts to FastAPI
A review of the entire pipeline, concept by concept:
REST Concept | FastAPI Implementation |
HTTP GET | @app.get() |
HTTP POST/PATCH/DELETE | @app.post() / @app.patch() / @app.delete() |
Resource URL | Route (decorator path) |
Path parameter | /tasks/{task_id} |
Query parameter | Function parameter with a default |
JSON request body | Pydantic model parameter |
HTTP status code | status_code= / raise HTTPException(...) |
Response shape | response_model |
Shared server state | Depends(get_store) |
Concurrent slow I/O | async def / await |
Verifying the contract | TestClient + pytest |
API documentation | OpenAPI + Swagger UI |
This table is the whole lecture compressed: every row on the left was a design decision you know how to make; every row on the right is the one line of FastAPI that carries it out.
6.25 Practice Exercises
6.25.1 Basic
E1. The intern’s API from Running Example: TerpTasks has at least eight distinct REST violations. List them, and for each name the section of these notes that fixes it.
E2. For each request, give the correct status code and one sentence of justification: (a) GET /tasks/99999 (never existed); (b) POST /tasks with unparseable JSON; (c) POST /tasks with valid JSON but due last week; (d) DELETE /tasks/7 twice in a row (second call); (e) any request while the database is down.
E3. Classify each method as safe and/or idempotent, and give the retry consequence: GET, POST, PUT, PATCH, DELETE.
E4. For each of the following, decide: path parameter, query parameter, or request body? (a) the id of the task to delete; (b) a flag to sort results by due date; (c) the new title when creating a task; (d) whether to show only tasks for CMSC389A.
E5. Explain why GET /deleteTask?id=42 is dangerous even on a server that requires login, naming the specific infrastructure behaviors that assume GET is safe. Then explain, in your own words, why GET /tasks/banana returns 422 without read_task ever running.
6.25.2 Intermediate
E6. Design (on paper) the endpoints for a new “course roster” feature: list students in a course, add a student to a course, remove a student. Decide path vs. query parameters for each, and defend your choices the way REST API Design Exercise did for tasks.
E7. Convert your E6 design into FastAPI decorators (@app.get(...), etc.), matching the comparison table in FastAPI Routes.
E8. Write the Pydantic models (RosterEntryCreate, RosterEntry) for E6/E7, following the “one model per direction” rule from Request Bodies and Pydantic.
E9. Add an overdue: bool query parameter to GET /tasks that, when true, returns only tasks whose due date is in the past and which are not done. Write the test first.
E10. Add a custom Pydantic validator to TaskCreate that rejects due dates more than 365 days in the future, and write tests for the boundary (exactly 365 days, 366 days).
6.25.3 Advanced
E11. Implement Build a Complete CRUD API’s complete TerpTasks API from scratch (models, storage, main), then use Swagger UI at /docs to exercise every endpoint by hand before running pytest.
E12. Persist tasks in SQLite so they survive restarts, changing only storage.py (and adding a dependency). The existing tests must pass against the new store. What did the get_store indirection buy you?
E13. Add token authentication: every mutating request must carry an Authorization: Bearer <token> header, checked by a dependency, returning 401 otherwise. GETs stay public. Hint: dependencies can read headers and can be attached to multiple endpoints.
E14. Generate a TerpTasks client with an AI assistant: paste your /openapi.json into the model, ask for a typed Python client class using httpx, and then verify the result by writing three tests that run the generated client against TestClient’s app. Document every discrepancy you had to fix.
6.26 Final Review
What is an API?
What makes an API RESTful?
What is the difference between a resource and an action?
When should you use a path parameter?
When should you use a query parameter?
What is the difference between PUT and POST?
When should an API return 201?
What does a Pydantic model do?
What does @app.get() do?
What is OpenAPI?
What is Swagger UI?
What does Depends buy you that a global variable does not?
When does async def actually help, and when does it hurt?
Why test with TestClient instead of manually curling a running server?
How does FastAPI implement REST concepts?
6.27 Summary
6.27.1 Key takeaways
Design before you build. REST is the design vocabulary for an HTTP API; FastAPI is the implementation tool. Learn the former first—
the framework is easy to swap, the contract is not. Nouns in URIs, verbs from HTTP. Collections and items (/tasks, /tasks/42); actions become state changes; Richardson Level 2 (resources + correct verbs + correct codes) is the working bar.
Methods are promises. Safe = no side effects; idempotent = repeat freely. Those properties are the retry policy for every client and proxy on the path.
Path identifies, query modifies. If a value changes which resource you mean, it’s a path parameter; if it changes how much or which subset, it’s a query parameter.
Status codes are for machines; never ship 200-with-an-error. One consistent error body, with a stable code field.
Type hints do triple duty in FastAPI: parsing, validation, and documentation all come from one declaration.
Separate models per direction (TaskCreate vs. Task); response_model is a guardrail against leaking fields.
Depends makes shared resources explicit and swappable, which is what makes the test suite clean; async def helps only when you await non-blocking I/O.
6.27.2 Terminology
Term | Meaning |
resource | a named thing the API exposes, identified by URI |
representation | the document (usually JSON) conveying a resource's state |
safe method | promises no server state change (GET) |
idempotent method | N repeats have the effect of 1 (GET, PUT, DELETE) |
uniform interface | same small verb set, self-descriptive messages, for everything |
statelessness | no per-client session memory on the server |
Richardson maturity | level 0 RPC -> 1 resources -> 2 verbs+codes -> 3 hypermedia |
path parameter | variable URL segment: /tasks/{task_id} |
query parameter | optional key--value pair after ? in the URL |
endpoint / path operation | a function bound to (method, path) by a decorator |
Pydantic model | a class whose type annotations define and enforce a schema |
validation | checking external data against a schema at the boundary |
response model | declared output schema; filters fields and feeds the docs |
dependency | a callable FastAPI runs for you, requested via Depends |
OpenAPI | machine-readable spec generated at /openapi.json; rendered at /docs |
ASGI | the async server–application interface; uvicorn speaks it to your app |
6.27.3 Common mistakes
Verbs in URLs (/getTask, /markTaskAsDone)—
the verb belongs to HTTP. Side effects behind GET (prefetchers will find them).
200 with an error in the body; or 500 for the client’s bad input.
PUT with a partial document (that’s PATCH), or forgetting exclude_unset=True in a PATCH handler.
Declaring /tasks/{task_id} before a more specific route like /tasks/count (specific routes must precede dynamic ones).
One Pydantic model for both input and output, letting clients set server-owned fields like id.
Depends(get_store())—
calling the dependency instead of passing it. Blocking calls (requests, time.sleep) inside async def.
Tests that share one store and pass or fail depending on execution order.
6.27.4 Connections
Backward: the API-as-contract framing echoes the technical-debt notes; testing an API with TestClient extends the testing lecture.
Forward: we will containerize TerpTasks with Docker so it runs identically anywhere; OWASP API security (broken auth on endpoints just like these); MCP—
REST APIs are what most MCP tools wrap, and honest status codes/self-describing errors are exactly what makes a tool safe for an AI agent to call in a loop; and when your Claude Code agent calls GitHub, everything here is what it’s relying on.