On this page:
6.1 Learning Objectives
6.2 Motivation:   Design the Contract, Then Build It
6.2.1 Why applications need APIs
6.2.2 Design the API before you pick a framework
6.2.3 Why REST won
6.2.4 Why Fast  API, specifically
6.3 Running Example:   Terp  Tasks
6.4 API Fundamentals
6.4.1 Client, server, request, response
6.4.2 An API is a contract
6.5 HTTP Fundamentals
6.6 HTTP Methods and CRUD
6.7 REST Resource Design
6.7.1 What is REST, formally?
6.7.2 Resources and URIs
6.7.3 The six constraints and the Richardson Maturity Model
6.8 Path Parameters vs Query Parameters
6.9 HTTP Status Codes
6.10 REST API Design Exercise
6.11 REST API code example
6.12 Transition from REST to Fast  API
6.13 Fast  API Introduction
6.14 Fast  API Routes
6.15 Path Parameters in Fast  API
6.16 Query Parameters in Fast  API
6.17 Request Bodies and Pydantic
6.18 Responses:   Models, Status Codes, and Errors
6.18.1 Response models
6.18.2 Errors with HTTPException
6.19 Swagger UI and Open  API
6.20 Dependency Injection with Depends
6.21 Build a Complete CRUD API
6.21.1 Shapes:   models.py
6.21.2 State:   storage.py
6.21.3 The boundary:   main.py
6.21.4 Running and testing the project
6.22 async def:   When It Matters
6.23 Testing Fast  API Applications
6.24 Connecting REST Concepts to Fast  API
6.25 Practice Exercises
6.25.1 Basic
6.25.2 Intermediate
6.25.3 Advanced
6.26 Final Review
6.27 Summary
6.27.1 Key takeaways
6.27.2 Terminology
6.27.3 Common mistakes
6.27.4 Connections
9.1

6 REST APIs and FastAPI: Designing and Building Web Interfaces🔗

    6.1 Learning Objectives

    6.2 Motivation: Design the Contract, Then Build It

      6.2.1 Why applications need APIs

      6.2.2 Design the API before you pick a framework

      6.2.3 Why REST won

      6.2.4 Why FastAPI, specifically

    6.3 Running Example: TerpTasks

    6.4 API Fundamentals

      6.4.1 Client, server, request, response

      6.4.2 An API is a contract

    6.5 HTTP Fundamentals

    6.6 HTTP Methods and CRUD

    6.7 REST Resource Design

      6.7.1 What is REST, formally?

      6.7.2 Resources and URIs

      6.7.3 The six constraints and the Richardson Maturity Model

    6.8 Path Parameters vs Query Parameters

    6.9 HTTP Status Codes

    6.10 REST API Design Exercise

    6.11 REST API code example

    6.12 Transition from REST to FastAPI

    6.13 FastAPI Introduction

    6.14 FastAPI Routes

    6.15 Path Parameters in FastAPI

    6.16 Query Parameters in FastAPI

    6.17 Request Bodies and Pydantic

    6.18 Responses: Models, Status Codes, and Errors

      6.18.1 Response models

      6.18.2 Errors with HTTPException

    6.19 Swagger UI and OpenAPI

    6.20 Dependency Injection with Depends

    6.21 Build a Complete CRUD API

      6.21.1 Shapes: models.py

      6.21.2 State: storage.py

      6.21.3 The boundary: main.py

      6.21.4 Running and testing the project

    6.22 async def: When It Matters

    6.23 Testing FastAPI Applications

    6.24 Connecting REST Concepts to FastAPI

    6.25 Practice Exercises

      6.25.1 Basic

      6.25.2 Intermediate

      6.25.3 Advanced

    6.26 Final Review

    6.27 Summary

      6.27.1 Key takeaways

      6.27.2 Terminology

      6.27.3 Common mistakes

      6.27.4 Connections

6.1 Learning Objectives🔗

By the end of this lecture you should be able to:

  1. Explain what a web API is, why applications need one, and describe the client–server request/response model as a contract between systems.

  2. Define REST as an architectural style, name its six constraints, and explain what each constraint buys you (and what ignoring it costs).

  3. Model a domain as resources: design URIs for collections and items, and refactor an RPC-style API into a resource-oriented one.

  4. Choose HTTP methods correctly using the safety and idempotency properties, including the PUT-vs-PATCH-vs-POST decision, and map CRUD operations onto them.

  5. Distinguish path parameters from query parameters, and decide which one a given piece of request data should be.

  6. Select appropriate status codes for common situations and design consistent, machine-readable error bodies.

  7. Build and run a FastAPI application with uvicorn, and use the automatically generated interactive documentation at /docs.

  8. Declare path parameters, query parameters, and request bodies in FastAPI using Python type hints, and predict how FastAPI validates each one.

  9. Define Pydantic models for requests and responses, and explain what happens when a client sends data that does not match the model.

  10. Use dependency injection (Depends) to share resources such as a data store across endpoints, and explain when async def endpoints help.

  11. Write automated tests for an API using TestClient and pytest.

  12. 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—GPT, Claude, Gemini—it sends an HTTP request to an API and parses the response. The web API is the universal interface between programs on the internet.

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—the framework is the pen; REST is the grammar. Getting the grammar right before you touch a framework matters because an API is a contract: once someone else’s code depends on it, every inconsistency you shipped becomes their permanent workaround. APIs are among the longest-lived interfaces in software; they routinely outlive the teams that created them. That’s why this lecture teaches REST first: the framework is easy to swap, the contract is not.

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—what architectural properties made that possible? Fielding distilled the answer into constraints, and “REST APIs” are what we call HTTP services that (mostly) follow them.

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—things with addresses (URIs) that you retrieve, replace, and delete with a small, universal set of verbs. Every proxy, cache, load balancer, browser, and HTTP library on Earth already understands that grammar. Fight it, and you fight the planet’s infrastructure; follow it, and that infrastructure works for you.

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—and it is the framework behind a large share of the ML/AI model-serving world—for three reasons:

  • 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—tsk) shipped this first draft over the weekend:

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 worksthe demo ran fine. Every section of this lecture will take a bite out of it: first we’ll redesign it as a proper REST API, then implement that design in FastAPI. Keep a running list of its sins as we go; there are at least eight.

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 “give me the list of tasks”), what information they must provide (“to create a task, send me a title and a due date”), and what they will get back (“a JSON object with an id field”).

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—you get a precise error code telling you what went wrong (404: we don’t have that; 422: your order form was filled out incorrectly).

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—and a promise the server must keep even as its internals change underneath. Representing data as JSON is what makes that promise easy to state and easy to parse on both ends; nearly every language has a JSON library built in or one API call away.

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)—the next two sections work out exactly what those conventions are and why.

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—they are machine-readable retry policy. A proxy that times out may re-send an idempotent request automatically; a crawler assumes GETs are harmless; your own client can blindly retry PUT/DELETE but must never blindly retry POST. When you choose a method, you are signing those promises on behalf of your implementation.

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—replace with this document converges. POST /tasks {"title":"Study"} sent three times: three tasks exist. Add one of these accumulates. That single distinction decides real outages: a flaky network plus a retrying client against a POST endpoint is the classic double-charge bug.

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 URIit can create (PUT /tasks/my-chosen-id) if the client names the resource. POST is ask the collection to make/do somethingthe server picks the URI and returns it in Location. The real axis is idempotency and who controls the URI, not create-vs-update. “Completing a task” is a good stress test of this rule: it feels like an action deserving its own endpoint (POST /tasks/42/complete), but ask what actually changes—one field of one resource. State change → represent it as state (done: true) and use the verb that means “change state” (PATCH).

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—the constraints themselves are the subject of the next subsection.

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—an entity, a collection of entities, or even an abstraction like “today’s schedule.” Each resource has at least one URI. Clients never touch resources directly; they exchange representations (typically JSON documents) of a resource’s current state.

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—but breaking them costs you the reader’s trust): plural nouns for collections; no verbs in paths; hierarchy only for true ownership; identifiers in the path, options in the query string; lowercase, hyphenated path segments.

Worked example—redesigning the intern’s URIs.

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:

  1. Client–serverUI concerns and data concerns are separated and evolve independently.

  2. Statelessevery request contains everything needed to process it; the server keeps no session memory between requests.

  3. Cacheableresponses declare whether (and how long) they may be reused.

  4. Uniform interfaceresources are identified by URIs; they’re manipulated through representations; messages are self-descriptive; a small universal verb set applies to everything.

  5. Layered systemclients can’t tell (and needn’t care) whether they’re talking to the origin server, a cache, or a proxy.

  6. 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—this is the industry’s working definition of “RESTful” and this lecture’s target. Level 3: hypermedia links in responses (HATEOAS), which is elegant and rarely deployed; we’ll note it and move on.

Worked example. Score the intern’s API: everything is a GET/POST on verb-named URLs (/getTask, /deleteTask)—Level 1 at best, and GET /deleteTask means a prefetcher or link-preview bot can delete data by merely looking at it. That’s the first concrete payoff of the constraints: verbs carry promises, and infrastructure acts on those promises whether you kept them or not.

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—it names one specific task. Removing it changes which thing you’re talking about.

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 collectionjust a filtered view of it. Nothing about “which resource” changed; only “how much of it” did.

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 relationshipevery task genuinely belongs to exactly one course, and “a course’s tasks” is a first-class concept clients navigate to. Compare that to /tasks?course=CMSC389A, which treats course as one of several independent filters (alongside done, priority, ...) on a single flat collection. Neither is “more correct”—these are API design choices, not absolute REST rules. The nesting is worth it only when the ownership is real and you’ll always need it; when in doubt, prefer the flatter query-string version, since it composes with other filters without multiplying URL shapes.

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—existence itself is information). Valid token, own task, already deleted → 404, and note that this makes DELETE idempotent but not oblivious: the second delete changes nothing (idempotent) yet honestly reports the resource is gone.

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"}the intern’s updateTask does exactly this—is lying to every piece of software in the chain: caches store the “success,” monitors see 100% uptime during a total outage, and clients must parse prose to learn they failed. A request that “fails” should still succeed at the HTTP level: a well-formed 404, not a crash and not a disguised 200.

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—and automates the check you would otherwise have to write by hand.

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—method, path, and any query parameters—before reading on. A design in the spirit of this lecture looks like:

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—and which choice does HTTP Methods and CRUD recommend, and why?

6.11 REST API code example🔗

The smallest REST API that is still REST: read-only tasks, standard library only—no Flask, no FastAPI, no Pydantic, and zero third-party dependencies. Save it as server.py.

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 shape

6.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—the resources are named, the verbs are chosen, the status codes are decided. FastAPI’s job is to turn that design into running code with as little ceremony as possible. This ordering matters pedagogically: if you learn FastAPI syntax before REST design, it is tempting to let the framework’s defaults be your design. Learn the design first, and FastAPI becomes a fast, faithful translator of decisions you already made deliberately.

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—so designing a good REST API is also designing a good tool surface for AI agents.

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—all from writing the code you’d want to write anyway.

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—a single file, main.py:

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 --reload

INFO:     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—use it in development only.

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.

Common misconceptions.
  • 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—we’ll come back to it in Swagger UI and OpenAPI once there’s a real API to look at.

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—that was already settled in REST API Design Exercise.

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—FastAPI rejects the request:

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.

Common misconceptions.
  • 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"which in Python is truthy! In frameworks without typed parsing this is a notorious bug (if request.args.get("done"): is true for both "true" and "false"). The type hint done: bool | None eliminates the entire bug class.

6.17 Request Bodies and Pydantic🔗

Creating a task requires the client to send structured data—a JSON body. This is where Pydantic enters.

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 default

To 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—empty title, bogus date:

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)—all without a single if statement in your code.

Common misconceptions.
  • “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 task

The 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 task

curl -i http://127.0.0.1:8000/tasks/999

HTTP/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—a bug, not a response.

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—every path, method, parameter, and schema—called the OpenAPI specification, available at /openapi.json. Swagger UI, served at /docs, renders that specification as an interactive page: every endpoint, its parameters, its schemas, and a button to execute requests right from the browser. A second rendering lives at /redoc.

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—it is a tool for documenting and interacting with an API that already exists. Deleting /docs would not change what your API does; it would only remove a convenient way to explore it. In team projects, /docs is how your front-end teammates learn your API—and the OpenAPI spec is excellent context to paste into an LLM when asking it to write a client for your service.

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 task

In 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—without modifying the endpoint at all. That substitution (app.dependency_overrides) is the payoff, and we use it in Testing FastAPI Applications.

Common misconceptions.
  • 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—plus one for partial updates:

"""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 = False

6.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—that is the point of the get_store() indirection from Dependency Injection with Depends.

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 store

One 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—and it’s why HTTP Methods and CRUD insisted PATCH and PUT are not interchangeable: a PUT handler should require the whole document, while a PATCH handler must not.

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—the running example from HTTP Methods and CRUD. A PUT handler is a straightforward variant: same route shape (@app.put("/tasks/{task_id}")), but it takes a Task-shaped body (all fields required) and calls store.add-style full replacement instead of exclude_unset=True merging. Many API sketches write “PUT/PATCH” as if interchangeable; by now you know why picking correctly matters.

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 suite

A 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 Content

6.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 awaitone 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—every other request in the process stalls. async def is not a “go faster” keyword; it is a promise that everything slow inside is awaited. When in doubt, plain def is the safe default, and it is what TerpTasks uses.

A realistic preview of where you will want async later in the course—proxying an LLM:

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-processno server, no network, no uvicornso tests are fast and deterministic.

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 == 422

Walk through test_create_then_fetch step by step:

  1. 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.

  2. 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.

  3. 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🔗
  1. 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.

  2. 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.

  3. E3. Classify each method as safe and/or idempotent, and give the retry consequence: GET, POST, PUT, PATCH, DELETE.

  4. 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.

  5. 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🔗
  1. 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.

  2. E7. Convert your E6 design into FastAPI decorators (@app.get(...), etc.), matching the comparison table in FastAPI Routes.

  3. E8. Write the Pydantic models (RosterEntryCreate, RosterEntry) for E6/E7, following the “one model per direction” rule from Request Bodies and Pydantic.

  4. 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.

  5. 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🔗
  1. 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.

  2. 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?

  3. 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.

  4. 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🔗
  1. 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.

  2. 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.

  3. Methods are promises. Safe = no side effects; idempotent = repeat freely. Those properties are the retry policy for every client and proxy on the path.

  4. 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.

  5. Status codes are for machines; never ship 200-with-an-error. One consistent error body, with a stable code field.

  6. Type hints do triple duty in FastAPI: parsing, validation, and documentation all come from one declaration.

  7. Separate models per direction (TaskCreate vs. Task); response_model is a guardrail against leaking fields.

  8. 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.