Programming with Python › Python for Automation and the Web › Day 82
Hands-on lab — Day 82: A First Web API with FastAPI
- ← Back to the Day 82 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-082-a-first-web-api-with-fastapi/
Commands
Setup
cd labs/sections/programming-with-python/day-082-a-first-web-api-with-fastapi
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import fastapi, pydantic; print(fastapi.__version__, pydantic.VERSION)" Run
.venv/bin/python3 examples/demo.py
.venv/bin/pytest examples
.venv/bin/pytest starter
.venv/bin/python3 starter/schema.py
.venv/bin/uvicorn api:app --host 127.0.0.1 --port 8123 --app-dir examples # optional: serves the app for real; no test needs it Test
bash tests/run_tests.sh File tree
examples/api.py examples/conftest.py examples/demo.py examples/models.py examples/pytest.ini examples/storage.py examples/test_api.py examples/test_type_demo.py examples/type_demo.py expected-output/openapi.json expected-output/pytest-examples.txt expected-output/sample-run.txt expected-output/starter-run.txt expected-output/test-run.txt metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/app.py starter/conftest.py starter/pytest.ini starter/schema.py starter/test_app.py tests/run_tests.sh troubleshooting.md
Lab README
Day 082 lab — Serve Something Real
Lesson
- Lesson title: A First Web API with FastAPI
- Day number: 82 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-082-a-first-web-api-with-fastapi
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-082-a-first-web-api-with-fastapiwhen the site is running.
Purpose
Four days ago you learned to be an HTTP client. On Day 82 you become the server, and this lab is where that inversion becomes concrete: every status code, header and JSON body you were reading is now something you choose and are answerable for.
You build a small bookmarks API — create, list with filtering, fetch one, update part of one, delete — and you build it the way a working one is built rather than the way a tutorial one is. That means four things:
- Separate models for input and output. The stored record carries an
internal
owner_token; the output model does not;response_modelis what stands between them. The suite asserts that field's absence explicitly, because a data leak is invisible until somebody looks. - Real validation at the boundary. A title must be non-empty and at
most 80 characters, a URL must actually be a URL, an unexpected field is
rejected rather than ignored, and every rejection is a 422 whose
structured
detailnames the exact field. - Deliberate status codes. 201 with a
Locationheader for creation, 204 with an empty body for deletion, 404 for a thing that is not there — an answer, not a crash. - Injected boundaries. Storage, the clock and the id source all arrive
through
Depends, so the tests hand the application an in-memory fake and no test touches a file, a database or a socket.
And the point the whole week has been building to: the tests drive the
application through TestClient, which speaks to the app object in this
process through httpx and opens no socket at all. That is the cleanest
possible form of Week 12's network rule — not a local server on an ephemeral
port that must be started and waited for and shut down, but no server. The
reference suite additionally runs behind a guard that raises on any outbound
connection, and section 7 of the harness proves the guard is armed by making
a test trip it on purpose.
The starter is the naive version — one shared model, a module-level dictionary, every response a 200, a missing bookmark a crash. Eight exercises turn it into the reference implementation.
Learning objectives
- Declare a path operation with FastAPI and explain what the decorator, the path string and the function signature each contribute.
- Use path parameters, query parameters with defaults and constraints, and a pydantic model as a request body, and describe how an annotation causes conversion and validation to happen at runtime.
- Read a 422 response: find the offending field in
detail[n].locand the reason indetail[n].type, and fix the request accordingly. - Declare a response model and state two things it buys you — a documented contract and a filter that stops internal fields leaving the process.
- Choose status codes deliberately: 200, 201 with
Location, 204, 404, 422, and the 500 you never choose. - Raise
HTTPExceptionfor a known negative answer, and explain why a traceback must never reach a client. - Inject a dependency with
Depends, override it withapp.dependency_overrides, and test the application without touching the real boundary. - Read the generated OpenAPI schema and check it against what you meant to promise, including checking that no internal field appears in it.
- Explain why
TestClientneeds no server, no port and no readiness wait.
Prerequisites
- The Day 82 lesson — read it first; this lab is its exercise set.
- Day 78: HTTP itself. Methods, status codes, headers, query strings, JSON request and response bodies. Today is the same vocabulary from the other side of the wire.
- Days 67–70: classes, dataclasses, and modelling a domain with objects. A pydantic model is a class whose annotations do work.
- Day 75: type annotations and what a static checker does with them. Today the same annotations are read by a different tool at a different moment.
- Day 74: injecting a boundary so a test can substitute a fake.
Dependsis that idea with framework support. - Days 71–73: pytest, fixtures, and writing tests that can fail.
- Day 43:
python3 -m venvand installing packages withpip. - A text editor, a terminal, and one-time network access to install five packages.
Supported operating systems
- macOS — fully supported (tested on macOS 26.5.1, Apple Silicon, Python 3.14.0, bash 3.2.57).
- Linux — fully supported (any distribution with Python 3.10 or newer,
bash and
pip). - Windows — use WSL and follow the Linux path. Native Windows works too:
substitute
pythonforpython3and.venv\Scripts\for.venv/bin/. Nothing here depends on path separators or line endings, and because no test binds a port, no firewall prompt ever appears.
Hardware requirements
Any computer that runs Python 3. The whole lab is a few dozen kilobytes of source; the reference suite runs 42 tests in under a second and uses a few tens of megabytes. No GPU, no special memory, no large download beyond the packages themselves.
Required software
python3— 3.11 or newer (the code usesdatetime.UTC, added in 3.11, andX | Noneannotations from 3.10). Tested on 3.14.0.fastapi0.139.2,uvicorn0.51.0,httpx0.28.1,pytest9.1.1 andpydantic2.13.4, all fromrequirements/requirements.txt.bashfor the test runner (preinstalled on macOS and Linux).- Standard library only in the lab's own logic:
json,datetime,pathlib,typing,secrets,uuid,os,itertools.
Free and open-source options
Everything here is free and open source, with no account, no key and no paid tier anywhere. FastAPI, Starlette, pydantic, uvicorn, httpx and pytest are all MIT-licensed and developed in the open.
If you would rather not install a framework at all, the standard library's
http.server will serve JSON over HTTP with no dependencies — and writing
even a two-route API with it makes vivid what FastAPI is doing for you,
because you will hand-write the routing, the JSON parsing, the validation and
every status code yourself. The lesson's Alternatives section compares
FastAPI with Flask, Django plus Django REST Framework, Litestar and
http.server, states plainly which are installed here (FastAPI is; Flask and
Django are not), and describes the others without inventing output for tools
that were never run.
Installation
cd labs/sections/programming-with-python/day-082-a-first-web-api-with-fastapi
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import fastapi, pydantic; print(fastapi.__version__, pydantic.VERSION)"
Expect 0.139.2 2.13.4. The install needs the network once. Nothing after
it does — see "Tests" below.
File structure
day-082-a-first-web-api-with-fastapi/
├── README.md this file
├── metadata.yml machine-readable lab record
├── troubleshooting.md real symptoms and their causes
├── security.md validation vs authorization, leaks, CORS, secrets
├── requirements/
│ ├── requirements.txt five pinned packages
│ └── README.md what each one is for, and why pydantic is pinned
├── starter/ YOUR work — runnable now, eight exercises
│ ├── app.py the naive version, with the exercises in place
│ ├── test_app.py 10 tests: 1 passing, 9 waiting for you
│ ├── schema.py Exercise 8 — print the generated contract
│ ├── conftest.py import path, clean slate, network guard
│ └── pytest.ini
├── examples/ the reference implementation
│ ├── models.py four pydantic models and why there are four
│ ├── storage.py a Protocol and two implementations
│ ├── api.py six routes, four injected dependencies
│ ├── type_demo.py a tiny app for conversion and the 500 case
│ ├── demo.py a narrated in-process session
│ ├── test_api.py 34 tests over the API
│ ├── test_type_demo.py 8 tests over conversion and error handling
│ ├── conftest.py import path and the network guard
│ └── pytest.ini
├── tests/
│ └── run_tests.sh the outer harness: 39 checks
└── expected-output/ captured from real runs on 2026-07-19
├── test-run.txt the full harness output
├── sample-run.txt examples/demo.py
├── pytest-examples.txt the reference suite, verbose
├── starter-run.txt the starter suite before you begin
└── openapi.json the generated contract, in full
How to run
From the lab directory, in this order:
## 1. See the finished API answer real requests, in-process.
.venv/bin/python3 examples/demo.py
## 2. Run the reference suite.
.venv/bin/pytest examples
## 3. See where you are starting from: 1 passed, 9 skipped.
.venv/bin/pytest starter
## 4. Ask your application what it currently promises.
.venv/bin/python3 starter/schema.py
## 5. Work through the eight exercises in starter/app.py, deleting the
## matching @pytest.mark.skip line each time, and rerun step 3.
## 6. The full harness — this is what has to pass.
bash tests/run_tests.sh
And, entirely optionally, run it as a real server:
.venv/bin/uvicorn api:app --reload --host 127.0.0.1 --port 8123 --app-dir examples
Then point a browser at /docs on that host and port for the interactive
documentation, or fetch /openapi.json for the machine-readable contract.
Stop it with Ctrl-C. No test needs this, and none of the captured
output came from it — it is here because an application you can only test is
not an application you can ship.
What the commands do
| Command | What it does | Why it is here |
|---|---|---|
python3 examples/demo.py |
Drives the finished API through TestClient and prints twelve exchanges with their status codes and bodies |
Shows the whole day in one page of output: 201 with a Location, two flavours of 422, a 404 with a detail, a partial update, a 204, and the leak check |
pytest examples |
Runs 42 tests over the reference implementation | The assertions the lesson claims exist, actually running |
pytest starter |
Runs your suite: 1 passing baseline, 9 skipped exercises | A green baseline first, so a later failure is your code and not your setup |
python3 starter/schema.py |
Prints the OpenAPI schema your app generates, then checks it for the leak | Exercise 8 — reading the contract the framework wrote from your annotations |
bash tests/run_tests.sh |
The outer harness: 39 checks across eight sections | The grader. It re-verifies every claim independently of the lab's own test files, and proves the leak check and the network guard are not vacuous |
uvicorn api:app --host 127.0.0.1 --port 8123 --app-dir examples |
Serves the app for real on the loopback interface | Optional. The honest answer to "but how do I actually run it?" |
Expected output
Every file in expected-output/ was captured from a real run on the
authoring machine on 19 July 2026 (macOS 26.5.1, Apple Silicon, Python
3.14.0, fastapi 0.139.2, pydantic 2.13.4, pytest 9.1.1). Absolute paths have
been replaced with <repo> and <venv>; nothing else was edited.
The harness ends with:
39 checks, 0 failure(s).
examples/demo.py opens like this — a valid creation, answered with a 201
and a Location header:
POST /bookmarks (a valid body)
-> 201
-> Location: /bookmarks/bm-0001
{
"id": "bm-0001",
"title": "The FastAPI documentation",
"url": "https://fastapi.tiangolo.com/",
"tags": [
"python",
"web"
],
"created_at": "2026-07-19T09:30:00Z"
}
and a request with two bad fields is answered with one 422 naming both:
POST /bookmarks (empty title, and the url is not a url)
-> 422
{
"detail": [
{
"type": "string_too_short",
"loc": [
"body",
"title"
],
"msg": "String should have at least 1 character",
"input": "",
"ctx": {
"min_length": 1
}
},
{
"type": "url_parsing",
"loc": [
"body",
"url"
],
"msg": "Input should be a valid URL, relative URL without a base",
"input": "not a url",
"ctx": {
"error": "relative URL without a base"
}
}
]
}
The leak check, at the end of the same run:
stored owner_token : secret-owner-token
owner_token in the response body? False
the secret string in the response body? False
The starter, before you begin, is 1 passed, 9 skipped. The full text of all
five captures is in expected-output/.
Validation steps
Work through these in order; each one is a thing you can check for yourself.
.venv/bin/pytest examplesexits 0 and reports42 passed..venv/bin/pytest starterexits 0 and reports1 passed, 9 skipped. Every skip reason names the exercise that removes it.python3 examples/demo.pyprints-> 201for the first creation andLocation: /bookmarks/bm-0001beneath it.- In that same output, the two-bad-fields request prints two entries
under
detail, one with"loc": ["body", "title"]and one with"loc": ["body", "url"]. Validation reports everything wrong at once. owner_token in the response body? Falseappears near the end. Then openexamples/api.pyand deleteresponse_model=BookmarkOut,from the create route, rerun, and watch it becomeTrue. Put the line back.python3 starter/schema.pyexits 0 and, before you start, printsNo BookmarkOut schema yet — Exercise 2 creates it.After Exercise 2 it prints the field list andNo leak: owner_token is stored but never declared as output.- Search
expected-output/openapi.jsonforowner_token. It is not there — the internal field is absent from the published contract as well as from the responses. bash tests/run_tests.shends39 checks, 0 failure(s).and exits 0.find . -name 'bookmarks.json'finds nothing after any of the above.
Tests
bash tests/run_tests.sh
Eight sections, 39 checks, exit 0 only if all of them pass:
- Versions — reprints every installed version and compares it against
requirements/requirements.txt, including pydantic, which nobody installed on purpose. - The reference suite — 42 tests pass, and the eight assertions the lesson names are confirmed to exist by test id, not just by count.
- Nineteen claims, re-verified independently of pytest — the harness
drives the same application from a plain script, so a broken test file
cannot make the lab look correct: 201 and the response shape, the
Locationheader, the 422 and the field it names, the 404 and its detail, the 204 and its empty body, the resource being gone, the internal field being stored and not sent, the schema and its paths and status codes, the injected storage, and no file on disk. - The demo script — runs and contains the exact fragments the lesson quotes.
- The starter — runs green with the exercises unfinished, and
schema.pyreports the unfinished state honestly. - The starter suite is not vacuous — the reference implementation is
dropped in as
app.py, the skips are stripped, and all 10 starter tests must pass; thenresponse_model=BookmarkOutis deleted from a copy and the suite must go RED, naming the leak check. A test that cannot fail protects nothing. - No socket — a throwaway test that deliberately calls
socket.create_connectionmust fail withNetworkAccessAttempted, proving the guard is armed; the reference run must show no such error; and no lab source may contain a real client call or a port bind. - No disk — no
bookmarks.jsonanywhere under the lab afterwards.
The tests need no network. Installing the packages does, once. After
that, everything runs offline and deterministically: TestClient drives the
application in-process, so there is no server to start, no port to pick, no
readiness loop to wait on, and nothing to leave running if a test fails.
Cleanup
rm -f bookmarks.json
rm -rf examples/.pytest_cache starter/.pytest_cache
find . -type d -name '__pycache__' -prune -exec rm -rf -- {} +
rm -rf .venv # optional: removes the lab virtual environment
git checkout -- starter/ # optional: reset your work
The tests themselves leave nothing behind. bookmarks.json only ever exists
if you ran the server by hand and created something through it.
Troubleshooting
See troubleshooting.md — it covers the missing-module
errors, the starlette/httpx deprecation notice you will genuinely see, how to
read a 422, why HttpUrl adds a trailing slash to your assertion, the
KeyError-instead-of-404 in the unfinished starter, the empty 500 body, the
307 from a trailing slash, why created_at moves until you inject the
clock, and the uvicorn invocation with --app-dir.
Security notes
See security.md. In short: nothing here opens a socket or
writes a file, and the harness proves both. The substantive content is the
five lessons the lab exists to teach — validation is not authorization,
never trust a client-supplied id, declare what you return or leak it, a
traceback is never a response, and secrets come from the environment while
CORS is a browser mechanism rather than a security feature. It also lists,
by name, the six protections this lab deliberately does not have.
Extension exercises
- Add
PUTbesidePATCH. APUTreplaces the whole resource, so its body model has no optional fields. Decide whatPUTto a non-existent id should do — 404, or create it and return 201 — and write the test that pins your decision. - Make the list endpoint paginated. Add
offsetalongsidelimit, return a total count, and decide whether the count goes in the body or in a header. Both are defensible; write down why you chose one. - Add a second internal field and try to leak it. Put
internal_noteonStoredBookmark, watch the existing tests stay green, then write the test that would have caught it. This is the honest way to learn what a test suite does not cover. - Swap the storage without touching a handler. Write a third
Storageimplementation — a CSV file, using Day 65's tools — and change onlyget_storage. If any handler needs editing, the boundary was not as clean as it looked. - Version the API. Move every route under
/v1/using anAPIRouterwith a prefix, and check the generated schema still lists everything. - Write the same two routes with
http.server. No framework: parse the path yourself, read the body, validate it by hand, choose the status code, and write the JSON. Then count the lines and decide what FastAPI was worth. - Add a dependency that fails. Write a
require_api_keydependency that raisesHTTPException(401)when a header is missing, apply it to the write routes, and note that your existing tests now need to supply it — which is exactly the moment authentication stops being free.
Navigation
- Previous lab: Day 081
- Next lab: Day 083
- Subsection index: Python in Practice
- Section index: Programming with Python
Expected output
openapi.json
{
"openapi": "3.1.0",
"info": {
"title": "Bookmarks API",
"summary": "A small, honest CRUD API used to learn FastAPI on Day 082.",
"description": "Create, list, read, update and delete bookmarks. Every request body is validated by a pydantic model; every response body is filtered through a response model so internal fields cannot leak.",
"version": "1.0.0"
},
"paths": {
"/health": {
"get": {
"tags": [
"meta"
],
"summary": "Health",
"description": "A liveness check.\n\nDeclared ``async def`` purely to show that it is allowed. This handler\ndoes no waiting, so it gains nothing from being a coroutine; a handler\nthat awaited a network call or a database driver would.",
"operationId": "health_health_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HealthOut"
}
}
}
}
}
}
},
"/bookmarks": {
"post": {
"tags": [
"bookmarks"
],
"summary": "Create a bookmark",
"description": "201 Created, with a Location header naming the new resource.\n\nThe return annotation is ``StoredBookmark`` \u2014 the object with the secret\nin it \u2014 and that is safe, because ``response_model=BookmarkOut`` filters\nthe response before it is serialized. The handler returns the truth; the\ndeclaration decides what the client is entitled to see.",
"operationId": "create_bookmark_bookmarks_post",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookmarkCreate"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookmarkOut"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"get": {
"tags": [
"bookmarks"
],
"summary": "List bookmarks, optionally filtered by tag",
"description": "``tag`` is optional; ``limit`` has a default and a validated range.\n\n``limit`` arrives from the query string as text and is handed to the\nhandler as an ``int``, because the annotation said ``int``. Ask for\n``?limit=0`` or ``?limit=abc`` and the handler is never entered: the\ncaller gets a 422 naming ``limit``.",
"operationId": "list_bookmarks_bookmarks_get",
"parameters": [
{
"name": "tag",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Return only bookmarks carrying this tag.",
"title": "Tag"
},
"description": "Return only bookmarks carrying this tag."
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"maximum": 100,
"minimum": 1,
"description": "Maximum number of bookmarks to return.",
"default": 20,
"title": "Limit"
},
"description": "Maximum number of bookmarks to return."
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/BookmarkOut"
},
"title": "Response List Bookmarks Bookmarks Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/bookmarks/{bookmark_id}": {
"get": {
"tags": [
"bookmarks"
],
"summary": "Fetch one bookmark",
"description": "404 when it is not there \u2014 an answer, not a crash.\n\n``HTTPException`` is how a handler says \"this request has a definite,\nnon-exceptional negative answer\". The client gets a small JSON body with\na ``detail`` string. It does not get a traceback, and it must not: a\ntraceback names your files, your line numbers and your local variables.",
"operationId": "get_bookmark_bookmarks__bookmark_id__get",
"parameters": [
{
"name": "bookmark_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bookmark Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookmarkOut"
}
}
}
},
"404": {
"description": "No bookmark with that id"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"patch": {
"tags": [
"bookmarks"
],
"summary": "Update part of a bookmark",
"description": "A partial update: only the fields the caller actually sent change.",
"operationId": "update_bookmark_bookmarks__bookmark_id__patch",
"parameters": [
{
"name": "bookmark_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bookmark Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookmarkUpdate"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookmarkOut"
}
}
}
},
"404": {
"description": "No bookmark with that id"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": {
"tags": [
"bookmarks"
],
"summary": "Delete a bookmark",
"description": "204 No Content: it worked, and there is deliberately nothing to say.\n\nA 204 body must be empty \u2014 that is what the status code means \u2014 so this\nhandler returns a bare ``Response`` rather than a model.",
"operationId": "delete_bookmark_bookmarks__bookmark_id__delete",
"parameters": [
{
"name": "bookmark_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bookmark Id"
}
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"404": {
"description": "No bookmark with that id"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"BookmarkCreate": {
"properties": {
"title": {
"type": "string",
"maxLength": 80,
"minLength": 1,
"title": "Title",
"description": "Human-readable name. Must not be empty."
},
"url": {
"type": "string",
"maxLength": 2083,
"minLength": 1,
"format": "uri",
"title": "Url",
"description": "Absolute http or https URL."
},
"tags": {
"items": {
"type": "string"
},
"type": "array",
"maxItems": 8,
"title": "Tags",
"description": "Up to eight short labels."
}
},
"additionalProperties": false,
"type": "object",
"required": [
"title",
"url"
],
"title": "BookmarkCreate",
"description": "The request body of ``POST /bookmarks``."
},
"BookmarkOut": {
"properties": {
"id": {
"type": "string",
"title": "Id"
},
"title": {
"type": "string",
"title": "Title"
},
"url": {
"type": "string",
"maxLength": 2083,
"minLength": 1,
"format": "uri",
"title": "Url"
},
"tags": {
"items": {
"type": "string"
},
"type": "array",
"title": "Tags"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
}
},
"type": "object",
"required": [
"id",
"title",
"url",
"tags",
"created_at"
],
"title": "BookmarkOut",
"description": "What a client receives. Deliberately a subset of ``StoredBookmark``."
},
"BookmarkUpdate": {
"properties": {
"title": {
"anyOf": [
{
"type": "string",
"maxLength": 80,
"minLength": 1
},
{
"type": "null"
}
],
"title": "Title"
},
"url": {
"anyOf": [
{
"type": "string",
"maxLength": 2083,
"minLength": 1,
"format": "uri"
},
{
"type": "null"
}
],
"title": "Url"
},
"tags": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array",
"maxItems": 8
},
{
"type": "null"
}
],
"title": "Tags"
}
},
"additionalProperties": false,
"type": "object",
"title": "BookmarkUpdate",
"description": "The request body of ``PATCH /bookmarks/{bookmark_id}``.\n\nEvery field is optional and defaults to ``None``, which is how a partial\nupdate says \"leave this one alone\". ``model_dump(exclude_unset=True)``\nthen tells you which fields the caller actually sent \u2014 note that this is\ngenuinely different from which fields are ``None``, because a caller may\nlegitimately send a field whose value is null."
},
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
},
"HealthOut": {
"properties": {
"status": {
"type": "string",
"title": "Status"
},
"bookmarks": {
"type": "integer",
"title": "Bookmarks"
}
},
"type": "object",
"required": [
"status",
"bookmarks"
],
"title": "HealthOut",
"description": "The body of ``GET /health``."
},
"ValidationError": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
},
"input": {
"title": "Input"
},
"ctx": {
"type": "object",
"title": "Context"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
}
}
}
}
pytest-examples.txt
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <venv>/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/programming-with-python/day-082-a-first-web-api-with-fastapi/examples
configfile: pytest.ini
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 42 items
examples/test_api.py::test_a_valid_create_returns_201 PASSED [ 2%]
examples/test_api.py::test_a_valid_create_returns_the_response_model_shape PASSED [ 4%]
examples/test_api.py::test_create_sets_a_location_header_naming_the_new_resource PASSED [ 7%]
examples/test_api.py::test_the_id_is_server_generated_and_a_client_cannot_choose_it PASSED [ 9%]
examples/test_api.py::test_an_empty_title_is_422_and_the_detail_names_the_field PASSED [ 11%]
examples/test_api.py::test_a_non_url_is_422_and_the_detail_names_the_field PASSED [ 14%]
examples/test_api.py::test_a_missing_required_field_is_422 PASSED [ 16%]
examples/test_api.py::test_two_bad_fields_produce_two_entries_in_one_response PASSED [ 19%]
examples/test_api.py::test_a_rejected_body_is_never_stored PASSED [ 21%]
examples/test_api.py::test_an_out_of_range_query_parameter_is_422 PASSED [ 23%]
examples/test_api.py::test_a_non_numeric_query_parameter_is_422 PASSED [ 26%]
examples/test_api.py::test_the_response_does_not_contain_the_internal_owner_token PASSED [ 28%]
examples/test_api.py::test_no_endpoint_leaks_the_internal_field PASSED [ 30%]
examples/test_api.py::test_the_openapi_schema_does_not_advertise_the_internal_field PASSED [ 33%]
examples/test_api.py::test_get_one_returns_what_was_created PASSED [ 35%]
examples/test_api.py::test_a_missing_bookmark_is_404_with_a_detail_and_no_traceback PASSED [ 38%]
examples/test_api.py::test_listing_returns_every_bookmark PASSED [ 40%]
examples/test_api.py::test_listing_filters_by_tag PASSED [ 42%]
examples/test_api.py::test_listing_respects_the_limit PASSED [ 45%]
examples/test_api.py::test_patch_changes_only_the_fields_that_were_sent PASSED [ 47%]
examples/test_api.py::test_patch_validates_too PASSED [ 50%]
examples/test_api.py::test_patching_a_missing_bookmark_is_404 PASSED [ 52%]
examples/test_api.py::test_delete_returns_204_with_an_empty_body PASSED [ 54%]
examples/test_api.py::test_after_delete_the_bookmark_is_gone PASSED [ 57%]
examples/test_api.py::test_deleting_a_missing_bookmark_is_404 PASSED [ 59%]
examples/test_api.py::test_health_reports_the_count PASSED [ 61%]
examples/test_api.py::test_the_openapi_schema_is_generated PASSED [ 64%]
examples/test_api.py::test_the_openapi_schema_contains_every_declared_path PASSED [ 66%]
examples/test_api.py::test_the_schema_records_the_status_codes_the_handlers_declared PASSED [ 69%]
examples/test_api.py::test_the_schema_records_the_validation_constraints PASSED [ 71%]
examples/test_api.py::test_the_interactive_documentation_is_served PASSED [ 73%]
examples/test_api.py::test_the_injected_storage_is_the_one_the_handlers_used PASSED [ 76%]
examples/test_api.py::test_no_file_was_written_anywhere_near_this_lab PASSED [ 78%]
examples/test_api.py::test_the_production_dependency_would_have_touched_a_file PASSED [ 80%]
examples/test_type_demo.py::test_a_path_parameter_is_converted_to_the_declared_type PASSED [ 83%]
examples/test_type_demo.py::test_a_path_parameter_that_cannot_convert_is_422 PASSED [ 85%]
examples/test_type_demo.py::test_query_defaults_apply_when_nothing_is_sent PASSED [ 88%]
examples/test_type_demo.py::test_a_missing_required_query_parameter_is_422 PASSED [ 90%]
examples/test_type_demo.py::test_a_boolean_query_parameter_accepts_the_spellings_http_carries PASSED [ 92%]
examples/test_type_demo.py::test_a_query_constraint_is_enforced PASSED [ 95%]
examples/test_type_demo.py::test_an_unhandled_exception_becomes_a_500_with_no_traceback PASSED [ 97%]
examples/test_type_demo.py::test_the_same_route_works_when_the_arguments_are_valid PASSED [100%]
============================== 42 passed in 0.24s ==============================
sample-run.txt
========================================================================
Bookmarks API — an in-process session (no server, no socket)
========================================================================
POST /bookmarks (a valid body)
-> 201
-> Location: /bookmarks/bm-0001
{
"id": "bm-0001",
"title": "The FastAPI documentation",
"url": "https://fastapi.tiangolo.com/",
"tags": [
"python",
"web"
],
"created_at": "2026-07-19T09:30:00Z"
}
POST /bookmarks (a second one, tagged differently)
-> 201
-> Location: /bookmarks/bm-0002
{
"id": "bm-0002",
"title": "The pydantic documentation",
"url": "https://docs.pydantic.dev/latest/",
"tags": [
"python",
"validation"
],
"created_at": "2026-07-19T09:30:00Z"
}
POST /bookmarks (empty title, and the url is not a url)
-> 422
{
"detail": [
{
"type": "string_too_short",
"loc": [
"body",
"title"
],
"msg": "String should have at least 1 character",
"input": "",
"ctx": {
"min_length": 1
}
},
{
"type": "url_parsing",
"loc": [
"body",
"url"
],
"msg": "Input should be a valid URL, relative URL without a base",
"input": "not a url",
"ctx": {
"error": "relative URL without a base"
}
}
]
}
POST /bookmarks (a client trying to choose its own id)
-> 422
{
"detail": [
{
"type": "extra_forbidden",
"loc": [
"body",
"id"
],
"msg": "Extra inputs are not permitted",
"input": "admin"
}
]
}
GET /bookmarks?tag=validation
-> 200
[
{
"id": "bm-0002",
"title": "The pydantic documentation",
"url": "https://docs.pydantic.dev/latest/",
"tags": [
"python",
"validation"
],
"created_at": "2026-07-19T09:30:00Z"
}
]
GET /bookmarks?limit=0 (out of range)
-> 422
{
"detail": [
{
"type": "greater_than_equal",
"loc": [
"query",
"limit"
],
"msg": "Input should be greater than or equal to 1",
"input": "0",
"ctx": {
"ge": 1
}
}
]
}
GET /bookmarks/bm-0001
-> 200
{
"id": "bm-0001",
"title": "The FastAPI documentation",
"url": "https://fastapi.tiangolo.com/",
"tags": [
"python",
"web"
],
"created_at": "2026-07-19T09:30:00Z"
}
GET /bookmarks/nope (does not exist)
-> 404
{
"detail": "No bookmark with id 'nope'"
}
PATCH /bookmarks/bm-0001 (title only)
-> 200
{
"id": "bm-0001",
"title": "FastAPI docs",
"url": "https://fastapi.tiangolo.com/",
"tags": [
"python",
"web"
],
"created_at": "2026-07-19T09:30:00Z"
}
DELETE /bookmarks/bm-0002
-> 204
(empty body)
GET /bookmarks/bm-0002 (after deletion)
-> 404
{
"detail": "No bookmark with id 'bm-0002'"
}
GET /health
-> 200
{
"status": "ok",
"bookmarks": 1
}
========================================================================
What the server kept, versus what it sent
========================================================================
stored owner_token : secret-owner-token
owner_token in the response body? False
the secret string in the response body? False
========================================================================
The contract FastAPI generated from the annotations
========================================================================
openapi version : 3.1.0
title / version : Bookmarks API 1.0.0
/bookmarks GET, POST
/bookmarks/{bookmark_id} DELETE, GET, PATCH
/health GET
component schemas: BookmarkCreate, BookmarkOut, BookmarkUpdate, HTTPValidationError, HealthOut, ValidationError
BookmarkOut fields: created_at, id, tags, title, url
starter-run.txt
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <venv>/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/programming-with-python/day-082-a-first-web-api-with-fastapi/starter
configfile: pytest.ini
plugins: cov-7.1.0, anyio-4.14.2
collecting ... collected 10 items
starter/test_app.py::test_health_is_ok_and_counts_bookmarks PASSED [ 10%]
starter/test_app.py::test_an_empty_title_is_422_naming_the_field SKIPPED [ 20%]
starter/test_app.py::test_a_non_url_is_422_naming_the_field SKIPPED [ 30%]
starter/test_app.py::test_the_response_never_contains_the_internal_owner_token SKIPPED [ 40%]
starter/test_app.py::test_a_client_cannot_choose_its_own_id SKIPPED [ 50%]
starter/test_app.py::test_create_returns_201_and_a_location_header SKIPPED [ 60%]
starter/test_app.py::test_listing_filters_by_tag_and_validates_limit SKIPPED [ 70%]
starter/test_app.py::test_a_missing_bookmark_is_404_and_not_a_traceback SKIPPED [ 80%]
starter/test_app.py::test_patch_then_delete SKIPPED (Exercise 6: PAT...) [ 90%]
starter/test_app.py::test_the_openapi_schema_declares_every_path_and_no_secret SKIPPED [100%]
=========================== short test summary info ============================
SKIPPED [1] starter/test_app.py:43: Exercise 1: constrain title and make url an HttpUrl
SKIPPED [1] starter/test_app.py:52: Exercise 1: constrain title and make url an HttpUrl
SKIPPED [1] starter/test_app.py:59: Exercise 2: split the model and add response_model
SKIPPED [1] starter/test_app.py:69: Exercise 2: extra='forbid' on the create model
SKIPPED [1] starter/test_app.py:76: Exercise 3: 201 Created and a Location header
SKIPPED [1] starter/test_app.py:85: Exercise 4: tag and limit query parameters
SKIPPED [1] starter/test_app.py:96: Exercise 5: 404 instead of a KeyError
SKIPPED [1] starter/test_app.py:104: Exercise 6: PATCH and DELETE
SKIPPED [1] starter/test_app.py:120: Exercise 8: the generated contract
========================= 1 passed, 9 skipped in 0.13s =========================
test-run.txt
Day 082 — Serve Something Real
1. The tools and the versions this lab was written against
fastapi==0.139.2
pydantic==2.13.4
starlette==1.3.1
uvicorn==0.51.0
httpx==0.28.1
pytest==9.1.1
ok: installed fastapi==0.139.2 matches requirements/requirements.txt
ok: installed uvicorn==0.51.0 matches requirements/requirements.txt
ok: installed httpx==0.28.1 matches requirements/requirements.txt
ok: installed pytest==9.1.1 matches requirements/requirements.txt
ok: pydantic 2.13.4 arrived as a FastAPI dependency
2. The reference suite passes
ok: pytest examples exits 0
ok: pytest examples reports 42 passed
ok: collection finds test_api.py::test_a_valid_create_returns_201
ok: collection finds test_api.py::test_an_empty_title_is_422_and_the_detail_names_the_field
ok: collection finds test_api.py::test_a_missing_bookmark_is_404_with_a_detail_and_no_traceback
ok: collection finds test_api.py::test_delete_returns_204_with_an_empty_body
ok: collection finds test_api.py::test_the_response_does_not_contain_the_internal_owner_token
ok: collection finds test_api.py::test_the_openapi_schema_contains_every_declared_path
ok: collection finds test_api.py::test_no_file_was_written_anywhere_near_this_lab
ok: collection finds test_type_demo.py::test_an_unhandled_exception_becomes_a_500_with_no_traceback
3. The seven claims, verified independently of pytest
PASS create returns 201
PASS create returns exactly the response-model shape
PASS create sets a Location header
PASS an invalid body returns 422
PASS the 422 detail names the offending field
PASS a missing resource returns 404
PASS the 404 body is a detail string, not a traceback
PASS delete returns 204
PASS the 204 body is empty
PASS the resource is gone afterwards
PASS the server really did store the internal field
PASS the response body has no owner_token key
PASS the response text does not contain the secret
PASS the OpenAPI schema is generated
PASS the schema contains every declared path
PASS the schema records the chosen status codes
PASS the public output schema has no owner_token
PASS the injected storage holds the records
PASS no bookmarks.json was written anywhere in the lab
ok: all independent claim checks passed
ok: all 19 claims were actually evaluated
4. The demo script runs and prints what the lesson quotes
ok: examples/demo.py exits 0
ok: demo output contains: Location: /bookmarks/bm-0001
ok: demo output contains: "type": "string_too_short"
ok: demo output contains: "type": "url_parsing"
ok: demo output contains: "type": "extra_forbidden"
ok: demo output contains: "detail": "No bookmark with id 'nope'"
ok: demo output contains: owner_token in the response body? False
ok: demo output contains: openapi version : 3.1.0
5. The starter is runnable before you start, and honest about it
ok: pytest starter exits 0 with the exercises unfinished
ok: the starter has 1 worked test and 9 skipped exercises
ok: starter/schema.py exits 0
ok: starter/schema.py reports the unfinished state honestly
ok: the starter still has one shared model (Exercise 2 splits it)
6. The starter suite is not vacuous — it fails on a broken app
ok: the starter suite goes fully green against the finished application
ok: all 10 starter tests pass once the exercises are done
ok: removing response_model makes the suite FAIL (exit 1, not 0)
ok: the failing run names the leak check by test id
7. Nothing opened a socket — and the guard that says so is real
ok: a test that tries to connect is stopped (exit 1, not 0)
ok: the guard raises NetworkAccessAttempted naming the address
ok: no test in the reference suite tripped the network guard
ok: no lab source opens a connection or binds a port
8. Nothing was written to disk
ok: no bookmarks.json anywhere under the lab after a full run
39 checks, 0 failure(s).
exit=0
Source files
examples/api.py (8105 bytes)
"""The bookmarks API — six routes, three injected boundaries, no globals.
Run it for real with an ASGI server:
uvicorn api:app --reload --host 127.0.0.1 --port 8123
Then open the interactive documentation at ``/docs`` on that host and port,
or fetch the machine-readable contract at ``/openapi.json``. The lab's tests
do none of that: they drive this same ``app`` object in-process through
``TestClient``, which opens no socket at all.
The three boundaries this module refuses to reach for:
* ``get_storage`` — where records live. Production: a JSON file. Tests:
an in-memory dictionary. The handlers never know which.
* ``get_now`` — the clock. Tests freeze it, so ``created_at`` is a
value you can assert on rather than a moving target.
* ``get_new_id`` — identifier generation. Tests make it a counter, so
the first bookmark is always ``bm-0001``.
Each is an ordinary function. ``Depends`` calls it per request and passes
the result in. ``app.dependency_overrides`` swaps it in a test. That is the
whole mechanism.
"""
from __future__ import annotations
import os
import secrets
from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated
from uuid import uuid4
from fastapi import Depends, FastAPI, HTTPException, Query, Response, status
from models import (
BookmarkCreate,
BookmarkOut,
BookmarkUpdate,
HealthOut,
StoredBookmark,
)
from storage import JsonFileStorage, Storage
app = FastAPI(
title="Bookmarks API",
version="1.0.0",
summary="A small, honest CRUD API used to learn FastAPI on Day 082.",
description=(
"Create, list, read, update and delete bookmarks. Every request body "
"is validated by a pydantic model; every response body is filtered "
"through a response model so internal fields cannot leak."
),
)
# --------------------------------------------------------------------------
# Dependencies — the three boundaries, each replaceable in a test.
# --------------------------------------------------------------------------
def get_storage() -> Storage:
"""Production storage: a JSON file whose path comes from the environment.
Reading configuration from the environment rather than hard-coding it is
the same rule Day 078 stated for tokens. The default is deliberately a
relative filename so that running the server in a scratch directory does
not scatter files somewhere surprising.
"""
return JsonFileStorage(Path(os.environ.get("BOOKMARKS_FILE", "bookmarks.json")))
def get_now() -> datetime:
"""Production clock. Timezone-aware and in UTC, always."""
return datetime.now(tz=UTC)
def get_new_id() -> str:
"""Production identifier source. Server-generated, never client-supplied."""
return uuid4().hex[:12]
def get_owner_token() -> str:
"""An internal per-record secret. Stored, never returned."""
return secrets.token_hex(8)
StorageDep = Annotated[Storage, Depends(get_storage)]
NowDep = Annotated[datetime, Depends(get_now)]
NewIdDep = Annotated[str, Depends(get_new_id)]
OwnerTokenDep = Annotated[str, Depends(get_owner_token)]
# --------------------------------------------------------------------------
# Routes
# --------------------------------------------------------------------------
@app.get("/health", response_model=HealthOut, tags=["meta"])
async def health(storage: StorageDep) -> HealthOut:
"""A liveness check.
Declared ``async def`` purely to show that it is allowed. This handler
does no waiting, so it gains nothing from being a coroutine; a handler
that awaited a network call or a database driver would.
"""
return HealthOut(status="ok", bookmarks=len(storage.all()))
@app.post(
"/bookmarks",
response_model=BookmarkOut,
status_code=status.HTTP_201_CREATED,
tags=["bookmarks"],
summary="Create a bookmark",
)
def create_bookmark(
payload: BookmarkCreate,
storage: StorageDep,
now: NowDep,
new_id: NewIdDep,
owner_token: OwnerTokenDep,
response: Response,
) -> StoredBookmark:
"""201 Created, with a Location header naming the new resource.
The return annotation is ``StoredBookmark`` — the object with the secret
in it — and that is safe, because ``response_model=BookmarkOut`` filters
the response before it is serialized. The handler returns the truth; the
declaration decides what the client is entitled to see.
"""
record = StoredBookmark(
id=new_id,
title=payload.title,
url=payload.url,
tags=payload.tags,
created_at=now,
owner_token=owner_token,
)
storage.add(record)
response.headers["Location"] = f"/bookmarks/{record.id}"
return record
@app.get(
"/bookmarks",
response_model=list[BookmarkOut],
tags=["bookmarks"],
summary="List bookmarks, optionally filtered by tag",
)
def list_bookmarks(
storage: StorageDep,
tag: Annotated[
str | None,
Query(description="Return only bookmarks carrying this tag."),
] = None,
limit: Annotated[
int,
Query(ge=1, le=100, description="Maximum number of bookmarks to return."),
] = 20,
) -> list[StoredBookmark]:
"""``tag`` is optional; ``limit`` has a default and a validated range.
``limit`` arrives from the query string as text and is handed to the
handler as an ``int``, because the annotation said ``int``. Ask for
``?limit=0`` or ``?limit=abc`` and the handler is never entered: the
caller gets a 422 naming ``limit``.
"""
items = storage.all()
if tag is not None:
items = [b for b in items if tag in b.tags]
return items[:limit]
@app.get(
"/bookmarks/{bookmark_id}",
response_model=BookmarkOut,
tags=["bookmarks"],
summary="Fetch one bookmark",
responses={404: {"description": "No bookmark with that id"}},
)
def get_bookmark(bookmark_id: str, storage: StorageDep) -> StoredBookmark:
"""404 when it is not there — an answer, not a crash.
``HTTPException`` is how a handler says "this request has a definite,
non-exceptional negative answer". The client gets a small JSON body with
a ``detail`` string. It does not get a traceback, and it must not: a
traceback names your files, your line numbers and your local variables.
"""
found = storage.get(bookmark_id)
if found is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No bookmark with id {bookmark_id!r}",
)
return found
@app.patch(
"/bookmarks/{bookmark_id}",
response_model=BookmarkOut,
tags=["bookmarks"],
summary="Update part of a bookmark",
responses={404: {"description": "No bookmark with that id"}},
)
def update_bookmark(
bookmark_id: str, payload: BookmarkUpdate, storage: StorageDep
) -> StoredBookmark:
"""A partial update: only the fields the caller actually sent change."""
found = storage.get(bookmark_id)
if found is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No bookmark with id {bookmark_id!r}",
)
changes = payload.model_dump(exclude_unset=True)
updated = found.model_copy(update=changes)
storage.replace(updated)
return updated
@app.delete(
"/bookmarks/{bookmark_id}",
status_code=status.HTTP_204_NO_CONTENT,
tags=["bookmarks"],
summary="Delete a bookmark",
responses={404: {"description": "No bookmark with that id"}},
)
def delete_bookmark(bookmark_id: str, storage: StorageDep) -> Response:
"""204 No Content: it worked, and there is deliberately nothing to say.
A 204 body must be empty — that is what the status code means — so this
handler returns a bare ``Response`` rather than a model.
"""
if not storage.delete(bookmark_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No bookmark with id {bookmark_id!r}",
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
examples/conftest.py (1738 bytes)
"""Shared pytest setup for the reference suite.
Two jobs:
1. Put this directory on ``sys.path`` so ``import api`` works no matter
where pytest was started from.
2. Prove the week's network rule mechanically. An autouse fixture replaces
the two functions that actually reach the network — ``socket.socket``'s
``connect`` and ``socket.create_connection`` — with functions that raise.
If any test in this suite tried to open a connection to anything, it
would fail with ``NetworkAccessAttempted`` naming the address. Nothing
here has to be trusted: the guard is armed for every test, and the suite
is green, so nothing connected.
``TestClient`` passes through this guard untouched, because it never opens a
connection. It hands the request object straight to the ASGI application in
the same process.
"""
from __future__ import annotations
import socket
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent))
class NetworkAccessAttempted(RuntimeError):
"""Raised if anything in the test run tries to open a connection."""
@pytest.fixture(autouse=True)
def no_network(monkeypatch: pytest.MonkeyPatch) -> None:
"""Arm the network guard for every test in this directory."""
def blocked_connect(self: socket.socket, address: object) -> None:
raise NetworkAccessAttempted(f"a test tried to connect to {address!r}")
def blocked_create_connection(address: object, *args: object, **kwargs: object) -> None:
raise NetworkAccessAttempted(f"a test tried to connect to {address!r}")
monkeypatch.setattr(socket.socket, "connect", blocked_connect)
monkeypatch.setattr(socket, "create_connection", blocked_create_connection)
examples/demo.py (5613 bytes)
"""A narrated walk through the bookmarks API, driven entirely in-process.
Run it:
python3 examples/demo.py
Every request below goes through ``TestClient``, so no server is started,
no port is bound and no socket is opened. The clock and the id source are
frozen so that this script prints the same thing every time — which is what
makes its output safe to check in as `expected-output/sample-run.txt`.
Read it top to bottom and you have the day's whole story: a created
resource with a 201 and a Location, a rejected body with a 422 that names
the field, a missing resource with a 404 instead of a crash, a partial
update, a 204 that means "gone", the internal field that never appears, and
the machine-readable contract the framework wrote for you.
"""
from __future__ import annotations
import itertools
import json
import sys
import warnings
from datetime import UTC, datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
# starlette 1.3.1 warns that a future release prefers httpx2 over the pinned
# httpx 0.28.1. The pinned pair works; the notice would only make this
# script's captured output noisier. troubleshooting.md explains it.
warnings.filterwarnings(
"ignore", message="Using `httpx` with `starlette.testclient` is deprecated"
)
import api # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from storage import InMemoryStorage # noqa: E402
def show(label: str, response: object) -> None:
"""Print one exchange the way a reader can check it."""
status = getattr(response, "status_code")
text = getattr(response, "text")
print(f"\n{label}")
print(f" -> {status}")
location = getattr(response, "headers").get("location")
if location:
print(f" -> Location: {location}")
if text:
try:
body = json.dumps(json.loads(text), indent=2, sort_keys=False)
except json.JSONDecodeError:
body = text
for line in body.splitlines():
print(f" {line}")
else:
print(" (empty body)")
def main() -> int:
store = InMemoryStorage()
counter = itertools.count(1)
api.app.dependency_overrides[api.get_storage] = lambda: store
api.app.dependency_overrides[api.get_now] = lambda: datetime(
2026, 7, 19, 9, 30, tzinfo=UTC
)
api.app.dependency_overrides[api.get_new_id] = lambda: f"bm-{next(counter):04d}"
api.app.dependency_overrides[api.get_owner_token] = lambda: "secret-owner-token"
client = TestClient(api.app)
print("=" * 72)
print("Bookmarks API — an in-process session (no server, no socket)")
print("=" * 72)
show(
"POST /bookmarks (a valid body)",
client.post(
"/bookmarks",
json={
"title": "The FastAPI documentation",
"url": "https://fastapi.tiangolo.com/",
"tags": ["python", "web"],
},
),
)
show(
"POST /bookmarks (a second one, tagged differently)",
client.post(
"/bookmarks",
json={
"title": "The pydantic documentation",
"url": "https://docs.pydantic.dev/latest/",
"tags": ["python", "validation"],
},
),
)
show(
"POST /bookmarks (empty title, and the url is not a url)",
client.post("/bookmarks", json={"title": "", "url": "not a url"}),
)
show(
"POST /bookmarks (a client trying to choose its own id)",
client.post(
"/bookmarks",
json={"title": "Sneaky", "url": "https://example.com/", "id": "admin"},
),
)
show("GET /bookmarks?tag=validation", client.get("/bookmarks?tag=validation"))
show("GET /bookmarks?limit=0 (out of range)", client.get("/bookmarks?limit=0"))
show("GET /bookmarks/bm-0001", client.get("/bookmarks/bm-0001"))
show("GET /bookmarks/nope (does not exist)", client.get("/bookmarks/nope"))
show(
"PATCH /bookmarks/bm-0001 (title only)",
client.patch("/bookmarks/bm-0001", json={"title": "FastAPI docs"}),
)
show("DELETE /bookmarks/bm-0002", client.delete("/bookmarks/bm-0002"))
show("GET /bookmarks/bm-0002 (after deletion)", client.get("/bookmarks/bm-0002"))
show("GET /health", client.get("/health"))
print("\n" + "=" * 72)
print("What the server kept, versus what it sent")
print("=" * 72)
kept = store.get("bm-0001")
assert kept is not None
print(f" stored owner_token : {kept.owner_token}")
sent = client.get("/bookmarks/bm-0001")
print(f" owner_token in the response body? {'owner_token' in sent.text}")
print(f" the secret string in the response body? {kept.owner_token in sent.text}")
print("\n" + "=" * 72)
print("The contract FastAPI generated from the annotations")
print("=" * 72)
schema = client.get("/openapi.json").json()
print(f" openapi version : {schema['openapi']}")
print(f" title / version : {schema['info']['title']} {schema['info']['version']}")
for path in sorted(schema["paths"]):
methods = ", ".join(sorted(m.upper() for m in schema["paths"][path]))
print(f" {path:28} {methods}")
print(" component schemas:", ", ".join(sorted(schema["components"]["schemas"])))
out_fields = sorted(schema["components"]["schemas"]["BookmarkOut"]["properties"])
print(" BookmarkOut fields:", ", ".join(out_fields))
api.app.dependency_overrides.clear()
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/models.py (3755 bytes)
"""The data contract of the bookmarks API, written as pydantic models.
Four models, and the reason there are four rather than one is the whole point
of this file:
* ``BookmarkCreate`` — what a caller is allowed to SEND when creating.
* ``BookmarkUpdate`` — what a caller is allowed to SEND when patching;
every field optional, because a PATCH changes some
fields and leaves the rest alone.
* ``StoredBookmark`` — what the server keeps INTERNALLY. It has one field
the outside world must never see: ``owner_token``.
* ``BookmarkOut`` — what a caller is allowed to RECEIVE.
A single shared model would have been fewer lines and a security bug. The
create model has no ``id`` and no ``created_at``, so a caller cannot choose
its own identifier or backdate a record; the output model has no
``owner_token``, so an internal secret cannot leak just because somebody
returned the wrong object. FastAPI enforces the output side for you: declare
``response_model=BookmarkOut`` on a handler and whatever the handler returns
is filtered down to those fields before it is serialized.
Note what the annotations are doing here, because it is different from
Day 069 and Day 075. There, an annotation was a claim a separate program
checked before you ran. Here the annotation is read at import time by
pydantic, compiled into a validator, and executed against real data at
runtime. Same syntax; a completely different job.
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, HttpUrl
# `extra="forbid"` turns an unexpected field into a 422 instead of silently
# ignoring it. Silently ignoring is the default and it is the reason people
# spend afternoons wondering why `titel="..."` had no effect.
STRICT = ConfigDict(extra="forbid")
class BookmarkCreate(BaseModel):
"""The request body of ``POST /bookmarks``."""
model_config = STRICT
title: str = Field(
min_length=1,
max_length=80,
description="Human-readable name. Must not be empty.",
)
url: HttpUrl = Field(description="Absolute http or https URL.")
tags: list[str] = Field(
default_factory=list,
max_length=8,
description="Up to eight short labels.",
)
class BookmarkUpdate(BaseModel):
"""The request body of ``PATCH /bookmarks/{bookmark_id}``.
Every field is optional and defaults to ``None``, which is how a partial
update says "leave this one alone". ``model_dump(exclude_unset=True)``
then tells you which fields the caller actually sent — note that this is
genuinely different from which fields are ``None``, because a caller may
legitimately send a field whose value is null.
"""
model_config = STRICT
title: str | None = Field(default=None, min_length=1, max_length=80)
url: HttpUrl | None = None
tags: list[str] | None = Field(default=None, max_length=8)
class StoredBookmark(BaseModel):
"""What the server keeps. Never returned to a client as-is."""
id: str
title: str
url: HttpUrl
tags: list[str]
created_at: datetime
owner_token: str
"""An internal server-side secret. If this ever appears in a response
body, the API has a data-leak bug. The lab's test suite asserts on its
absence explicitly, because a leak is invisible until someone looks."""
class BookmarkOut(BaseModel):
"""What a client receives. Deliberately a subset of ``StoredBookmark``."""
id: str
title: str
url: HttpUrl
tags: list[str]
created_at: datetime
class HealthOut(BaseModel):
"""The body of ``GET /health``."""
status: str
bookmarks: int
examples/pytest.ini (579 bytes)
[pytest]
# Pins the rootdir so test ids are stable wherever pytest is launched from.
testpaths = .
python_files = test_*.py
# starlette 1.3.1 emits a deprecation notice when its TestClient is backed by
# httpx 0.28.x rather than the newer httpx2. The pinned combination works
# correctly — all tests pass — and the notice is about a future version, not
# about this run. It is silenced here only so the captured output stays
# readable; troubleshooting.md explains it rather than hiding it.
filterwarnings =
ignore:Using `httpx` with `starlette.testclient` is deprecated
examples/storage.py (4042 bytes)
"""Storage for the bookmarks API — a Protocol and two implementations.
Day 074 argued that anything crossing a boundary should arrive as an
argument rather than be reached for. This file is that argument applied to
persistence, and the API module never names either class: it asks for a
``Storage`` and FastAPI's ``Depends`` hands one over.
``Storage`` is a ``typing.Protocol`` (Day 075). Neither implementation
inherits from it and neither needs to register anywhere; they satisfy it by
having the right methods with the right signatures. That is what lets the
test suite hand the application an ``InMemoryStorage`` while production runs
``JsonFileStorage``, with no shared base class and no flag inside the
handlers saying "if testing".
Nothing here opens a socket. ``JsonFileStorage`` writes one JSON file;
``InMemoryStorage`` writes nothing at all.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Protocol
from models import StoredBookmark
class Storage(Protocol):
"""The shape the API needs. Anything with these five methods fits."""
def add(self, bookmark: StoredBookmark) -> None: ...
def all(self) -> list[StoredBookmark]: ...
def get(self, bookmark_id: str) -> StoredBookmark | None: ...
def replace(self, bookmark: StoredBookmark) -> None: ...
def delete(self, bookmark_id: str) -> bool: ...
class InMemoryStorage:
"""A dictionary with the Storage shape. What the tests inject.
This is not a mock library object and nothing is patched. It is a real,
small, correct implementation of the same contract — which is why tests
written against it exercise the handlers honestly rather than exercising
a stub of them.
"""
def __init__(self, initial: list[StoredBookmark] | None = None) -> None:
self._items: dict[str, StoredBookmark] = {b.id: b for b in (initial or [])}
def add(self, bookmark: StoredBookmark) -> None:
self._items[bookmark.id] = bookmark
def all(self) -> list[StoredBookmark]:
return list(self._items.values())
def get(self, bookmark_id: str) -> StoredBookmark | None:
return self._items.get(bookmark_id)
def replace(self, bookmark: StoredBookmark) -> None:
self._items[bookmark.id] = bookmark
def delete(self, bookmark_id: str) -> bool:
return self._items.pop(bookmark_id, None) is not None
class JsonFileStorage:
"""The real one: a JSON file on disk, read and rewritten on every call.
Rewriting the whole file per call is fine for a few hundred bookmarks and
wrong for a million; Week 13 replaces it with a database. What matters
today is that this class is the only thing in the lab that touches the
filesystem, and the API never mentions it by name.
"""
def __init__(self, path: Path) -> None:
self.path = path
def _read(self) -> dict[str, StoredBookmark]:
if not self.path.exists():
return {}
raw = json.loads(self.path.read_text(encoding="utf-8"))
return {item["id"]: StoredBookmark.model_validate(item) for item in raw}
def _write(self, items: dict[str, StoredBookmark]) -> None:
payload = [json.loads(b.model_dump_json()) for b in items.values()]
self.path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
def add(self, bookmark: StoredBookmark) -> None:
items = self._read()
items[bookmark.id] = bookmark
self._write(items)
def all(self) -> list[StoredBookmark]:
return list(self._read().values())
def get(self, bookmark_id: str) -> StoredBookmark | None:
return self._read().get(bookmark_id)
def replace(self, bookmark: StoredBookmark) -> None:
items = self._read()
items[bookmark.id] = bookmark
self._write(items)
def delete(self, bookmark_id: str) -> bool:
items = self._read()
if items.pop(bookmark_id, None) is None:
return False
self._write(items)
return True
examples/test_api.py (13749 bytes)
"""The reference suite for the bookmarks API.
Every test here drives the application through ``TestClient``. That class
wraps httpx and speaks to the ASGI app object directly, in this process, in
this thread's event loop. There is no server, no port, no socket, and no
race between "started" and "ready". ``conftest.py`` arms a guard that would
raise if anything did try to connect; the suite is green, so nothing did.
Three dependencies are overridden for every test — storage, the clock and
the id source — via ``app.dependency_overrides``. That dictionary maps the
production dependency function to the one you want instead, and FastAPI
consults it on every request. Nothing inside ``api.py`` is patched, and the
handlers cannot tell the difference; they simply receive what they asked
for.
"""
from __future__ import annotations
import itertools
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
import api
import pytest
from fastapi.testclient import TestClient
from storage import InMemoryStorage
FROZEN_NOW = datetime(2026, 7, 19, 9, 30, tzinfo=UTC)
FROZEN_NOW_JSON = "2026-07-19T09:30:00Z"
@pytest.fixture
def storage() -> InMemoryStorage:
"""The fake the application will be given. A real, correct dictionary."""
return InMemoryStorage()
@pytest.fixture
def client(
storage: InMemoryStorage, monkeypatch: pytest.MonkeyPatch
) -> Iterator[TestClient]:
"""A TestClient wired to the fake storage, a frozen clock and counted ids."""
# If the production file storage is ever constructed during a test, that
# is a bug in the wiring and this makes it loud rather than silent.
def refuse(*args: object, **kwargs: object) -> None:
raise AssertionError("the real JsonFileStorage was constructed in a test")
monkeypatch.setattr(api, "JsonFileStorage", refuse)
counter = itertools.count(1)
api.app.dependency_overrides[api.get_storage] = lambda: storage
api.app.dependency_overrides[api.get_now] = lambda: FROZEN_NOW
api.app.dependency_overrides[api.get_new_id] = lambda: f"bm-{next(counter):04d}"
api.app.dependency_overrides[api.get_owner_token] = lambda: "secret-owner-token"
with TestClient(api.app) as test_client:
yield test_client
api.app.dependency_overrides.clear()
VALID = {
"title": "The FastAPI documentation",
"url": "https://fastapi.tiangolo.com/",
"tags": ["python", "web"],
}
# --------------------------------------------------------------------------
# Creating
# --------------------------------------------------------------------------
def test_a_valid_create_returns_201(client: TestClient) -> None:
response = client.post("/bookmarks", json=VALID)
assert response.status_code == 201
def test_a_valid_create_returns_the_response_model_shape(client: TestClient) -> None:
body = client.post("/bookmarks", json=VALID).json()
assert set(body) == {"id", "title", "url", "tags", "created_at"}
assert body["id"] == "bm-0001"
assert body["title"] == "The FastAPI documentation"
assert body["url"] == "https://fastapi.tiangolo.com/"
assert body["tags"] == ["python", "web"]
assert body["created_at"] == FROZEN_NOW_JSON
def test_create_sets_a_location_header_naming_the_new_resource(
client: TestClient,
) -> None:
response = client.post("/bookmarks", json=VALID)
assert response.headers["location"] == "/bookmarks/bm-0001"
# And the header is not decorative: it addresses something real.
assert client.get(response.headers["location"]).status_code == 200
def test_the_id_is_server_generated_and_a_client_cannot_choose_it(
client: TestClient,
) -> None:
response = client.post("/bookmarks", json={**VALID, "id": "admin"})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "id"]
# --------------------------------------------------------------------------
# Validation: 422 and its structured detail
# --------------------------------------------------------------------------
def test_an_empty_title_is_422_and_the_detail_names_the_field(
client: TestClient,
) -> None:
response = client.post("/bookmarks", json={**VALID, "title": ""})
assert response.status_code == 422
detail = response.json()["detail"]
assert detail[0]["loc"] == ["body", "title"]
assert detail[0]["type"] == "string_too_short"
def test_a_non_url_is_422_and_the_detail_names_the_field(client: TestClient) -> None:
response = client.post("/bookmarks", json={**VALID, "url": "not a url"})
assert response.status_code == 422
detail = response.json()["detail"]
assert detail[0]["loc"] == ["body", "url"]
assert "url" in detail[0]["type"]
def test_a_missing_required_field_is_422(client: TestClient) -> None:
response = client.post("/bookmarks", json={"title": "No URL here"})
assert response.status_code == 422
assert response.json()["detail"][0]["type"] == "missing"
assert response.json()["detail"][0]["loc"] == ["body", "url"]
def test_two_bad_fields_produce_two_entries_in_one_response(
client: TestClient,
) -> None:
"""Validation reports everything wrong at once, not the first thing."""
response = client.post("/bookmarks", json={"title": "", "url": "nope"})
assert response.status_code == 422
locs = [tuple(item["loc"]) for item in response.json()["detail"]]
assert ("body", "title") in locs
assert ("body", "url") in locs
def test_a_rejected_body_is_never_stored(
client: TestClient, storage: InMemoryStorage
) -> None:
client.post("/bookmarks", json={**VALID, "title": ""})
assert storage.all() == []
def test_an_out_of_range_query_parameter_is_422(client: TestClient) -> None:
response = client.get("/bookmarks", params={"limit": 0})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", "limit"]
def test_a_non_numeric_query_parameter_is_422(client: TestClient) -> None:
response = client.get("/bookmarks", params={"limit": "many"})
assert response.status_code == 422
assert response.json()["detail"][0]["type"] == "int_parsing"
# --------------------------------------------------------------------------
# The leak check — this is the one worth reading twice
# --------------------------------------------------------------------------
def test_the_response_does_not_contain_the_internal_owner_token(
client: TestClient, storage: InMemoryStorage
) -> None:
response = client.post("/bookmarks", json=VALID)
# The server really did store the secret ...
stored = storage.get("bm-0001")
assert stored is not None
assert stored.owner_token == "secret-owner-token"
# ... and it is absent from the response, by key and by raw text.
assert "owner_token" not in response.json()
assert "secret-owner-token" not in response.text
def test_no_endpoint_leaks_the_internal_field(client: TestClient) -> None:
"""Every route that returns a bookmark is checked, not just create."""
client.post("/bookmarks", json=VALID)
for response in (
client.get("/bookmarks"),
client.get("/bookmarks/bm-0001"),
client.patch("/bookmarks/bm-0001", json={"title": "Renamed"}),
):
assert response.status_code == 200
assert "owner_token" not in response.text
assert "secret-owner-token" not in response.text
def test_the_openapi_schema_does_not_advertise_the_internal_field(
client: TestClient,
) -> None:
schema = client.get("/openapi.json").json()
assert "owner_token" not in schema["components"]["schemas"]["BookmarkOut"][
"properties"
]
# --------------------------------------------------------------------------
# Reading, filtering, updating, deleting
# --------------------------------------------------------------------------
def test_get_one_returns_what_was_created(client: TestClient) -> None:
client.post("/bookmarks", json=VALID)
body = client.get("/bookmarks/bm-0001").json()
assert body["title"] == "The FastAPI documentation"
def test_a_missing_bookmark_is_404_with_a_detail_and_no_traceback(
client: TestClient,
) -> None:
response = client.get("/bookmarks/does-not-exist")
assert response.status_code == 404
assert response.json() == {"detail": "No bookmark with id 'does-not-exist'"}
assert "Traceback" not in response.text
assert "File \"" not in response.text
def test_listing_returns_every_bookmark(client: TestClient) -> None:
client.post("/bookmarks", json=VALID)
client.post("/bookmarks", json={**VALID, "title": "Second", "tags": ["python"]})
assert len(client.get("/bookmarks").json()) == 2
def test_listing_filters_by_tag(client: TestClient) -> None:
client.post("/bookmarks", json=VALID)
client.post(
"/bookmarks", json={**VALID, "title": "Second", "tags": ["scheduling"]}
)
body = client.get("/bookmarks", params={"tag": "scheduling"}).json()
assert [item["title"] for item in body] == ["Second"]
def test_listing_respects_the_limit(client: TestClient) -> None:
for index in range(5):
client.post("/bookmarks", json={**VALID, "title": f"Item {index}"})
assert len(client.get("/bookmarks", params={"limit": 2}).json()) == 2
def test_patch_changes_only_the_fields_that_were_sent(client: TestClient) -> None:
client.post("/bookmarks", json=VALID)
body = client.patch("/bookmarks/bm-0001", json={"title": "Renamed"}).json()
assert body["title"] == "Renamed"
assert body["url"] == "https://fastapi.tiangolo.com/"
assert body["tags"] == ["python", "web"]
def test_patch_validates_too(client: TestClient) -> None:
client.post("/bookmarks", json=VALID)
response = client.patch("/bookmarks/bm-0001", json={"title": ""})
assert response.status_code == 422
def test_patching_a_missing_bookmark_is_404(client: TestClient) -> None:
assert client.patch("/bookmarks/nope", json={"title": "x"}).status_code == 404
def test_delete_returns_204_with_an_empty_body(client: TestClient) -> None:
client.post("/bookmarks", json=VALID)
response = client.delete("/bookmarks/bm-0001")
assert response.status_code == 204
assert response.content == b""
def test_after_delete_the_bookmark_is_gone(client: TestClient) -> None:
client.post("/bookmarks", json=VALID)
client.delete("/bookmarks/bm-0001")
assert client.get("/bookmarks/bm-0001").status_code == 404
assert client.get("/bookmarks").json() == []
def test_deleting_a_missing_bookmark_is_404(client: TestClient) -> None:
assert client.delete("/bookmarks/nope").status_code == 404
def test_health_reports_the_count(client: TestClient) -> None:
client.post("/bookmarks", json=VALID)
assert client.get("/health").json() == {"status": "ok", "bookmarks": 1}
# --------------------------------------------------------------------------
# The generated contract
# --------------------------------------------------------------------------
def test_the_openapi_schema_is_generated(client: TestClient) -> None:
response = client.get("/openapi.json")
assert response.status_code == 200
schema = response.json()
assert schema["info"]["title"] == "Bookmarks API"
assert schema["info"]["version"] == "1.0.0"
assert schema["openapi"].startswith("3.")
def test_the_openapi_schema_contains_every_declared_path(client: TestClient) -> None:
paths = client.get("/openapi.json").json()["paths"]
assert set(paths) == {"/health", "/bookmarks", "/bookmarks/{bookmark_id}"}
assert set(paths["/bookmarks"]) == {"get", "post"}
assert set(paths["/bookmarks/{bookmark_id}"]) == {"get", "patch", "delete"}
def test_the_schema_records_the_status_codes_the_handlers_declared(
client: TestClient,
) -> None:
paths = client.get("/openapi.json").json()["paths"]
assert "201" in paths["/bookmarks"]["post"]["responses"]
assert "204" in paths["/bookmarks/{bookmark_id}"]["delete"]["responses"]
assert "404" in paths["/bookmarks/{bookmark_id}"]["get"]["responses"]
assert "422" in paths["/bookmarks"]["post"]["responses"]
def test_the_schema_records_the_validation_constraints(client: TestClient) -> None:
create = client.get("/openapi.json").json()["components"]["schemas"][
"BookmarkCreate"
]
assert create["properties"]["title"]["minLength"] == 1
assert create["properties"]["title"]["maxLength"] == 80
assert create["required"] == ["title", "url"]
def test_the_interactive_documentation_is_served(client: TestClient) -> None:
assert client.get("/docs").status_code == 200
# --------------------------------------------------------------------------
# The boundary really was injected
# --------------------------------------------------------------------------
def test_the_injected_storage_is_the_one_the_handlers_used(
client: TestClient, storage: InMemoryStorage
) -> None:
client.post("/bookmarks", json=VALID)
assert [b.id for b in storage.all()] == ["bm-0001"]
def test_no_file_was_written_anywhere_near_this_lab(client: TestClient) -> None:
client.post("/bookmarks", json=VALID)
lab_dir = Path(__file__).resolve().parent.parent
assert not (lab_dir / "bookmarks.json").exists()
assert not (lab_dir / "examples" / "bookmarks.json").exists()
assert not (Path.cwd() / "bookmarks.json").exists()
def test_the_production_dependency_would_have_touched_a_file() -> None:
"""The control: without the override, ``get_storage`` builds file storage.
Called directly here — not through a request — so nothing is written.
"""
from storage import JsonFileStorage
assert isinstance(api.get_storage(), JsonFileStorage)
examples/test_type_demo.py (3223 bytes)
"""Tests for the type-conversion demo, including what a 500 looks like.
The last two tests are the ones the lesson leans on: a validation failure is
a 422 that tells the caller exactly what to fix, and a bug in your code is a
500 that tells the caller nothing at all. Both are correct behaviour. The
difference is who the problem belongs to.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from type_demo import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def test_a_path_parameter_is_converted_to_the_declared_type(
client: TestClient,
) -> None:
body = client.get("/items/7").json()
assert body == {"item_id": 7, "python_type": "int"}
def test_a_path_parameter_that_cannot_convert_is_422(client: TestClient) -> None:
response = client.get("/items/seven")
assert response.status_code == 422
detail = response.json()["detail"][0]
assert detail["loc"] == ["path", "item_id"]
assert detail["type"] == "int_parsing"
assert detail["input"] == "seven"
def test_query_defaults_apply_when_nothing_is_sent(client: TestClient) -> None:
body = client.get("/search", params={"q": "fastapi"}).json()
assert body == {"q": "fastapi", "page": 1, "verbose": False, "sort": None}
def test_a_missing_required_query_parameter_is_422(client: TestClient) -> None:
response = client.get("/search")
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", "q"]
assert response.json()["detail"][0]["type"] == "missing"
def test_a_boolean_query_parameter_accepts_the_spellings_http_carries(
client: TestClient,
) -> None:
for spelling in ("true", "True", "1", "yes", "on"):
body = client.get("/search", params={"q": "x", "verbose": spelling}).json()
assert body["verbose"] is True
for spelling in ("false", "0", "no", "off"):
body = client.get("/search", params={"q": "x", "verbose": spelling}).json()
assert body["verbose"] is False
def test_a_query_constraint_is_enforced(client: TestClient) -> None:
response = client.get("/search", params={"q": "x", "sort": "a" * 17})
assert response.status_code == 422
assert response.json()["detail"][0]["type"] == "string_too_long"
def test_an_unhandled_exception_becomes_a_500_with_no_traceback() -> None:
"""The client learns that it broke, and learns nothing else.
``raise_server_exceptions=False`` makes TestClient behave the way a real
ASGI server does instead of re-raising the exception into the test. The
body is the five-word default, and it contains no filename, no line
number and no variable value.
"""
with TestClient(app, raise_server_exceptions=False) as client:
response = client.get("/ratio/1/0")
assert response.status_code == 500
assert response.text == "Internal Server Error"
assert "ZeroDivisionError" not in response.text
assert "Traceback" not in response.text
assert "type_demo.py" not in response.text
def test_the_same_route_works_when_the_arguments_are_valid() -> None:
with TestClient(app) as client:
assert client.get("/ratio/3/4").json() == {"result": 0.75}
examples/type_demo.py (1788 bytes)
"""A three-route application whose only job is to show type conversion.
Everything arriving in a URL is text. ``/items/7`` carries the two
characters ``7``, not the number seven. The annotation is what turns one
into the other — and what produces a 422 when the text cannot be turned
into the declared type at all.
Nothing in here is part of the bookmarks API; it exists so the lesson can
quote real output for the smallest possible case.
"""
from __future__ import annotations
from typing import Annotated
from fastapi import FastAPI, Query
app = FastAPI(title="Type conversion demo", version="1.0.0")
@app.get("/items/{item_id}")
def read_item(item_id: int) -> dict[str, object]:
"""``item_id`` is declared ``int``, so the handler receives an int."""
return {"item_id": item_id, "python_type": type(item_id).__name__}
@app.get("/search")
def search(
q: str,
page: int = 1,
verbose: bool = False,
sort: Annotated[str | None, Query(max_length=16)] = None,
) -> dict[str, object]:
"""``q`` is required; the other three have defaults, so they are optional.
``verbose`` accepts the spellings HTTP actually carries — ``true``,
``1``, ``yes``, ``on`` and their opposites — and hands the handler a
real ``bool``.
"""
return {"q": q, "page": page, "verbose": verbose, "sort": sort}
@app.get("/ratio/{numerator}/{denominator}")
def ratio(numerator: float, denominator: float) -> dict[str, float]:
"""Deliberately unguarded, to show what an unhandled exception does.
Ask for ``/ratio/1/0`` and this raises ``ZeroDivisionError``. Nothing
catches it, so the caller gets a 500 with a five-word body while the
traceback stays in the server log where it belongs.
"""
return {"result": numerator / denominator}
metadata.yml (1652 bytes)
lesson_id: D082
day: 82
kind: python-program
languages: [python, bash]
setup_commands:
- cd labs/sections/programming-with-python/day-082-a-first-web-api-with-fastapi
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
- .venv/bin/python3 -c "import fastapi, pydantic; print(fastapi.__version__, pydantic.VERSION)"
run_commands:
- .venv/bin/python3 examples/demo.py
- .venv/bin/pytest examples
- .venv/bin/pytest starter
- .venv/bin/python3 starter/schema.py
- '.venv/bin/uvicorn api:app --host 127.0.0.1 --port 8123 --app-dir examples # optional: serves the app for real; no test needs it'
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- rm -f bookmarks.json
- rm -rf examples/.pytest_cache starter/.pytest_cache
- "find . -type d -name '__pycache__' -prune -exec rm -rf -- {} +"
- 'rm -rf .venv # optional: removes the lab virtual environment'
- 'git checkout -- starter/ # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-19'
executed_on: 'macOS 26.5.1 (Apple Silicon), Python 3.14.0, fastapi 0.139.2, pydantic 2.13.4, starlette 1.3.1, uvicorn 0.51.0, httpx 0.28.1, pytest 9.1.1, bash 3.2.57 — bash tests/run_tests.sh -> 39 checks, 0 failure(s), exit 0; pytest examples -> 42 passed; pytest starter -> 1 passed, 9 skipped. Network is needed once to install the five pinned packages; the tests themselves open no socket at all — they drive the application in-process through TestClient, and section 7 of the harness proves the network guard is armed by making a throwaway test trip it deliberately.'
requirements/README.md (4330 bytes)
# Dependencies for the Day 082 lab
Five packages, all free and open source, all installed from the Python
Package Index with `pip`, all running entirely on your own machine.
| Package | Pinned version | Why this lab needs it |
| --- | --- | --- |
| `fastapi` | `0.139.2` | The web framework the lesson is about. It reads your type annotations and turns them into request parsing, validation, serialization and a machine-readable OpenAPI schema. |
| `uvicorn` | `0.51.0` | The ASGI server that runs the application for real. The lab's tests do not use it — they use `TestClient` — but the README shows the command, because an application you can only test is not an application you can ship. |
| `httpx` | `0.28.1` | The HTTP client library `TestClient` is built on. You never call it directly; installing it is what makes `from fastapi.testclient import TestClient` work. |
| `pytest` | `9.1.1` | The test runner from Days 071–074. Nothing new today except what it is pointed at. |
| `pydantic` | `2.13.4` | Not requested directly — **it arrives because FastAPI depends on it**, and it is pinned here so that the validation messages you see match the captured output. It is the piece that makes an annotation do work at runtime. |
## Why pydantic is pinned even though nobody asked for it
`pip install fastapi` installs pydantic whether you name it or not. Leaving
it unpinned means a future pydantic release could change an error `type`
string — `string_too_short`, `url_parsing`, `int_parsing` — and the lab's
assertions, which check those strings, would fail for reasons that have
nothing to do with your code.
The pinned number was read from the installed package rather than assumed:
```bash
.venv/bin/python3 -c "from importlib.metadata import version; print(version('pydantic'))"
```
On the authoring machine, on 19 July 2026, that printed `2.13.4`, and the
first section of `tests/run_tests.sh` reprints every installed version and
compares it against this file — so a mismatch is reported rather than
discovered later as a mysterious failure.
`starlette` (version 1.3.1 here) also arrives as a FastAPI dependency and is
deliberately **not** pinned: FastAPI itself constrains which versions it
accepts, and pinning it separately is how you eventually get an unsolvable
dependency conflict.
## Licences
FastAPI, Starlette, pydantic, uvicorn, httpx and pytest are all distributed
under the MIT licence, stated on each project's own documentation site. Every
one is maintained in the open, costs nothing, and needs no account, no key
and no signup — personally or commercially.
## One-time install
From the lab directory:
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import fastapi, pydantic; print(fastapi.__version__, pydantic.VERSION)"
```
Expect `0.139.2 2.13.4`. Day 43 covered `python3 -m venv` in full; this is
the same pattern. The environment lives in `.venv/` inside the lab, is
already excluded from version control, and can be deleted at any time with
`rm -rf .venv`.
## Network
Installing needs the network, once. **Nothing else in this lab does.** The
tests drive the application in-process through `TestClient`, and the
reference suite runs behind a guard that raises if anything tries to open a
connection — section 7 of `tests/run_tests.sh` proves that guard is armed by
making a test trip it deliberately.
## Running without a lab-local environment
If you already have these packages available in an environment you have
activated, the test runner will find `pytest` on your `PATH`. You can also
point it at a specific binary:
```bash
PYTEST=/path/to/pytest bash tests/run_tests.sh
```
The runner uses the `python3` that sits beside that `pytest`, because that is
the interpreter with FastAPI installed. If FastAPI is not importable from it,
the runner says so and stops rather than skipping checks quietly.
## A note you will see in your own output
With this pinned combination, `starlette` 1.3.1 emits one deprecation notice
saying a future release prefers `httpx2` over `httpx`. Everything works —
all 42 reference tests pass on it — and the two `pytest.ini` files filter the
notice so the captured output stays readable. `../troubleshooting.md`
explains it rather than pretending it is not there.
requirements/requirements.txt (78 bytes)
fastapi==0.139.2
uvicorn==0.51.0
httpx==0.28.1
pytest==9.1.1
pydantic==2.13.4
starter/app.py (7173 bytes)
"""Your bookmarks API — a working skeleton with eight exercises.
This file RUNS right now. Prove it before you change anything:
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest starter -q
You should see one test pass and nine skipped. Each skipped test names the
exercise that makes it pass. Work through them in order; after each one,
rerun the command above and delete the `@pytest.mark.skip` line from the
test you just satisfied.
What is here already is deliberately the *naive* version — the version
somebody writes on the first afternoon and regrets on the second:
* one shared model for input and output, so a client can set fields it
has no business setting, and the server returns fields it should never
send;
* a module-level dictionary for storage, which Day 074 told you makes a
boundary untestable;
* every response a 200, because nobody chose a status code;
* no error handling, so a missing bookmark is a crash.
The exercises turn it into the version in `examples/`. Run the reference
implementation any time you want to see where you are heading:
python3 examples/demo.py
To run this app for real, from the lab directory:
.venv/bin/uvicorn app:app --reload --host 127.0.0.1 --port 8123 --app-dir starter
Then visit /docs on that host and port in a browser. The tests never do
this — they use TestClient, which drives the app in this process and opens
no socket.
"""
from __future__ import annotations
import secrets
from datetime import UTC, datetime
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(title="Bookmarks API", version="0.1.0")
# EXERCISE 7 replaces this module-level dictionary with an injected
# dependency. Until then, storage is a global — which is exactly why the
# tests below cannot start from a clean slate without reaching in and
# clearing it.
BOOKMARKS: dict[str, "Bookmark"] = {}
class Bookmark(BaseModel):
"""One model doing three jobs badly.
EXERCISE 1: give `title` a minimum length of 1 and a maximum of 80, and
change `url` from `str` to pydantic's `HttpUrl`, so that an
empty title and the text "not a url" are both rejected with
a 422 before any handler runs.
Import them with: from pydantic import Field, HttpUrl
EXERCISE 2: split this into three models.
BookmarkCreate — title, url, tags. No id, no created_at,
no owner_token. Add
model_config = ConfigDict(extra="forbid")
so an unexpected field is a 422 rather
than being silently dropped.
StoredBookmark — everything, including owner_token.
BookmarkOut — everything EXCEPT owner_token.
Then put `response_model=BookmarkOut` on every handler that
returns a bookmark. That single declaration is what stops
the internal field leaving the process.
"""
id: str = ""
title: str
url: str
tags: list[str] = []
created_at: datetime | None = None
owner_token: str = ""
@app.get("/health")
async def health() -> dict[str, object]:
"""Already correct, and already `async def` — which is allowed and,
here, gains nothing, because this handler never waits for anything."""
return {"status": "ok", "bookmarks": len(BOOKMARKS)}
@app.post("/bookmarks")
def create_bookmark(payload: Bookmark) -> Bookmark:
"""Creates a bookmark and returns 200.
EXERCISE 3: a creation is a 201, not a 200. Add
`status_code=status.HTTP_201_CREATED` to the decorator
(import `status` from fastapi), and set a Location header
naming the new resource. To set a header, add a parameter
`response: Response` and assign
`response.headers["Location"] = f"/bookmarks/{record.id}"`.
"""
record = payload.model_copy(
update={
"id": secrets.token_hex(4),
"created_at": datetime.now(tz=UTC),
"owner_token": secrets.token_hex(8),
}
)
BOOKMARKS[record.id] = record
return record
@app.get("/bookmarks")
def list_bookmarks() -> list[Bookmark]:
"""Returns everything, always.
EXERCISE 4: add two query parameters.
tag: str | None = None — return only bookmarks carrying
this tag; absent means "all".
limit: int = 20 — how many at most. Constrain it
with
Annotated[int, Query(ge=1, le=100)]
so that ?limit=0 is a 422 that
names `limit`.
"""
return list(BOOKMARKS.values())
@app.get("/bookmarks/{bookmark_id}")
def get_bookmark(bookmark_id: str) -> Bookmark:
"""Crashes with a KeyError when the bookmark is not there.
EXERCISE 5: look the bookmark up, and when it is missing raise
HTTPException(status_code=404, detail=...) instead. A
missing thing is an ANSWER, not a failure — and a client
must never receive a traceback, because a traceback names
your files, your line numbers and your local variables.
"""
return BOOKMARKS[bookmark_id]
# EXERCISE 6: add the two routes this API is missing.
#
# @app.patch("/bookmarks/{bookmark_id}") — a partial update. Take a
# BookmarkUpdate model whose fields are all optional, and apply only
# the ones the caller actually sent:
# changes = payload.model_dump(exclude_unset=True)
# updated = found.model_copy(update=changes)
# 404 when the bookmark does not exist.
#
# @app.delete("/bookmarks/{bookmark_id}") — status_code 204. A 204 means
# "it worked and there is deliberately nothing to say", so the body
# must be empty: return Response(status_code=204). 404 when there was
# nothing to delete.
#
# EXERCISE 7: stop using the BOOKMARKS global.
# Write a `get_storage()` function that returns a storage object, annotate
# each handler's storage parameter as
# Annotated[Storage, Depends(get_storage)]
# and let FastAPI pass it in. Then a test can swap it with
# app.dependency_overrides[get_storage] = lambda: InMemoryStorage()
# and no test ever touches a real file. Do the same for the clock
# (`get_now`) and the id source (`get_new_id`) so `created_at` and `id`
# become values a test can assert on rather than moving targets.
# `examples/storage.py` has a Protocol and two implementations to copy.
#
# EXERCISE 8: ask the application what it now promises.
# Run `python3 starter/schema.py` to print the generated OpenAPI schema.
# Check that every path you declared is there, that the 201, 204 and 404
# you chose are recorded, and that `owner_token` appears nowhere in
# `components.schemas.BookmarkOut.properties`.
starter/conftest.py (1583 bytes)
"""Setup for the starter suite: import path, a clean slate, and a net guard.
The `clean_slate` fixture exists only because `app.py` currently keeps its
bookmarks in a module-level dictionary. Reaching into another module to
reset a global before every test is exactly the smell Day 074 described,
and Exercise 7 removes the need for it: once storage is injected, each test
constructs its own and this fixture can be deleted.
The `no_network` guard is the same one the reference suite uses. It makes
the week's network rule mechanical rather than a promise.
"""
from __future__ import annotations
import socket
import sys
import warnings
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent))
warnings.filterwarnings(
"ignore", message="Using `httpx` with `starlette.testclient` is deprecated"
)
class NetworkAccessAttempted(RuntimeError):
"""Raised if anything in the test run tries to open a connection."""
@pytest.fixture(autouse=True)
def no_network(monkeypatch: pytest.MonkeyPatch) -> None:
def blocked(self: socket.socket, address: object) -> None:
raise NetworkAccessAttempted(f"a test tried to connect to {address!r}")
def blocked_create(address: object, *a: object, **k: object) -> None:
raise NetworkAccessAttempted(f"a test tried to connect to {address!r}")
monkeypatch.setattr(socket.socket, "connect", blocked)
monkeypatch.setattr(socket, "create_connection", blocked_create)
@pytest.fixture(autouse=True)
def clean_slate() -> None:
import app
app.BOOKMARKS.clear()
starter/pytest.ini (366 bytes)
[pytest]
testpaths = .
python_files = test_*.py
# starlette 1.3.1 notes that a future release prefers httpx2 over the pinned
# httpx 0.28.1. The pinned pair works — every test here passes on it — and
# troubleshooting.md explains the notice rather than pretending it is absent.
filterwarnings =
ignore:Using `httpx` with `starlette.testclient` is deprecated
starter/schema.py (2095 bytes)
"""Print what your application promises, and check it for a leak.
python3 starter/schema.py
This is Exercise 8. FastAPI builds this document from your annotations —
you never write it — and it is the same document `/openapi.json` serves and
the same document the interactive `/docs` page renders. A machine-readable
contract is the entire point of declaring types at the boundary: another
program can read this and generate a client, or a test, or a tool
definition, without a human explaining anything.
The last line is the leak check. If `owner_token` appears in the output
schema, some handler is returning the stored object without a
`response_model` to filter it.
"""
from __future__ import annotations
import json
import sys
import warnings
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
warnings.filterwarnings(
"ignore", message="Using `httpx` with `starlette.testclient` is deprecated"
)
from app import app # noqa: E402
def main() -> int:
schema = app.openapi()
print(f"OpenAPI version : {schema['openapi']}")
print(f"Title / version : {schema['info']['title']} {schema['info']['version']}")
print("\nPaths and the status codes each one declares:")
for path in sorted(schema["paths"]):
for method in sorted(schema["paths"][path]):
codes = ", ".join(sorted(schema["paths"][path][method]["responses"]))
print(f" {method.upper():7} {path:28} -> {codes}")
print("\nComponent schemas:", ", ".join(sorted(schema["components"]["schemas"])))
out = schema["components"]["schemas"].get("BookmarkOut")
if out is None:
print("\nNo BookmarkOut schema yet — Exercise 2 creates it.")
return 0
fields = sorted(out["properties"])
print("BookmarkOut fields:", ", ".join(fields))
if "owner_token" in fields:
print("\nLEAK: owner_token is part of the public output schema.")
return 1
print("\nNo leak: owner_token is stored but never declared as output.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
starter/test_app.py (5140 bytes)
"""Your exercise suite. One test passes now; nine are waiting for you.
Run it:
.venv/bin/pytest starter -q
Each skipped test names the exercise in `app.py` that makes it pass. Do the
exercise, delete that test's `@pytest.mark.skip(...)` line, rerun. When all
nine are green, `starter/app.py` does what `examples/api.py` does, and you
wrote it.
Everything here goes through `TestClient`, which drives the application in
this process. No server, no port, no socket — `conftest.py` arms a guard
that would raise if anything tried.
"""
from __future__ import annotations
import pytest
from app import app
from fastapi.testclient import TestClient
VALID = {
"title": "The FastAPI documentation",
"url": "https://fastapi.tiangolo.com/",
"tags": ["python", "web"],
}
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def test_health_is_ok_and_counts_bookmarks(client: TestClient) -> None:
"""This one passes already. It is here so you always have a green
baseline: if it ever fails, the problem is your setup, not your code."""
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok", "bookmarks": 0}
@pytest.mark.skip(reason="Exercise 1: constrain title and make url an HttpUrl")
def test_an_empty_title_is_422_naming_the_field(client: TestClient) -> None:
response = client.post("/bookmarks", json={**VALID, "title": ""})
assert response.status_code == 422
detail = response.json()["detail"][0]
assert detail["loc"] == ["body", "title"]
assert detail["type"] == "string_too_short"
@pytest.mark.skip(reason="Exercise 1: constrain title and make url an HttpUrl")
def test_a_non_url_is_422_naming_the_field(client: TestClient) -> None:
response = client.post("/bookmarks", json={**VALID, "url": "not a url"})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "url"]
@pytest.mark.skip(reason="Exercise 2: split the model and add response_model")
def test_the_response_never_contains_the_internal_owner_token(
client: TestClient,
) -> None:
"""The leak check. A leak is invisible until somebody asserts on it."""
response = client.post("/bookmarks", json=VALID)
assert "owner_token" not in response.json()
assert "owner_token" not in response.text
@pytest.mark.skip(reason="Exercise 2: extra='forbid' on the create model")
def test_a_client_cannot_choose_its_own_id(client: TestClient) -> None:
response = client.post("/bookmarks", json={**VALID, "id": "admin"})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "id"]
@pytest.mark.skip(reason="Exercise 3: 201 Created and a Location header")
def test_create_returns_201_and_a_location_header(client: TestClient) -> None:
response = client.post("/bookmarks", json=VALID)
assert response.status_code == 201
location = response.headers["location"]
assert location.startswith("/bookmarks/")
assert client.get(location).status_code == 200
@pytest.mark.skip(reason="Exercise 4: tag and limit query parameters")
def test_listing_filters_by_tag_and_validates_limit(client: TestClient) -> None:
client.post("/bookmarks", json=VALID)
client.post("/bookmarks", json={**VALID, "title": "Cron", "tags": ["scheduling"]})
filtered = client.get("/bookmarks", params={"tag": "scheduling"}).json()
assert [item["title"] for item in filtered] == ["Cron"]
assert client.get("/bookmarks", params={"limit": 0}).status_code == 422
@pytest.mark.skip(reason="Exercise 5: 404 instead of a KeyError")
def test_a_missing_bookmark_is_404_and_not_a_traceback(client: TestClient) -> None:
response = client.get("/bookmarks/does-not-exist")
assert response.status_code == 404
assert "detail" in response.json()
assert "Traceback" not in response.text
@pytest.mark.skip(reason="Exercise 6: PATCH and DELETE")
def test_patch_then_delete(client: TestClient) -> None:
created = client.post("/bookmarks", json=VALID).json()
bookmark_id = created["id"]
patched = client.patch(f"/bookmarks/{bookmark_id}", json={"title": "Renamed"})
assert patched.status_code == 200
assert patched.json()["title"] == "Renamed"
assert patched.json()["url"] == "https://fastapi.tiangolo.com/"
deleted = client.delete(f"/bookmarks/{bookmark_id}")
assert deleted.status_code == 204
assert deleted.content == b""
assert client.get(f"/bookmarks/{bookmark_id}").status_code == 404
@pytest.mark.skip(reason="Exercise 8: the generated contract")
def test_the_openapi_schema_declares_every_path_and_no_secret(
client: TestClient,
) -> None:
schema = client.get("/openapi.json").json()
assert set(schema["paths"]) >= {
"/health",
"/bookmarks",
"/bookmarks/{bookmark_id}",
}
assert "201" in schema["paths"]["/bookmarks"]["post"]["responses"]
assert "204" in schema["paths"]["/bookmarks/{bookmark_id}"]["delete"]["responses"]
out = schema["components"]["schemas"]["BookmarkOut"]["properties"]
assert "owner_token" not in out
tests/run_tests.sh (18189 bytes)
#!/usr/bin/env bash
# Tests for the Day 082 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# This harness proves seven specific claims the lesson makes, and it proves
# them by running the application rather than by reading it:
#
# * a valid create returns 201 and exactly the response-model shape;
# * an invalid body returns 422 whose structured detail NAMES the field;
# * a missing resource returns 404 with a detail string, not a traceback;
# * DELETE returns 204 with an empty body and the resource is then gone;
# * the response never contains the internal owner_token — the leak check,
# asserted by absence, because a leak is invisible until somebody looks;
# * the OpenAPI schema is generated and contains every declared path;
# * the injected fake storage means no real file was written anywhere.
#
# And an eighth, which is this week's rule made mechanical: the reference
# suite runs behind a guard that raises on any outbound connection, and
# section 7 below proves that guard is not decorative by making a test trip
# it on purpose.
#
# Everything runs in-process through FastAPI's TestClient. No server is
# started, no port is bound, no socket is opened. Deterministic,
# non-interactive, exits 0 only if every check passes.
set -u
export PYTHONDONTWRITEBYTECODE=1
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failures=0
checks=0
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
# Resolve pytest: an explicit override, then this lab's .venv, then whatever
# is on PATH. Fails loudly with instructions rather than silently skipping.
resolve_tool() {
local tool="$1" override="$2"
if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
return 1
}
pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
echo "FAIL: pytest not found." >&2
echo " Install it with:" >&2
echo " python3 -m venv .venv" >&2
echo " .venv/bin/pip install -r requirements/requirements.txt" >&2
echo " Or point this suite at an existing pytest: PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
exit 1
}
# The Python that owns that pytest is the one with fastapi installed.
python_bin="$(dirname "${pytest_bin}")/python3"
if [ ! -x "${python_bin}" ]; then
python_bin="$(command -v python3 || true)"
fi
if [ -z "${python_bin}" ]; then
echo "FAIL: python3 not found on PATH." >&2
exit 1
fi
if ! "${python_bin}" -c "import fastapi" >/dev/null 2>&1; then
echo "FAIL: fastapi is not importable from ${python_bin}." >&2
echo " Install the lab's dependencies with:" >&2
echo " python3 -m venv .venv" >&2
echo " .venv/bin/pip install -r requirements/requirements.txt" >&2
exit 1
fi
echo "Day 082 — Serve Something Real"
echo
# --------------------------------------------------------------------------
echo "1. The tools and the versions this lab was written against"
# --------------------------------------------------------------------------
versions="$("${python_bin}" - <<'PY'
from importlib.metadata import version
for name in ("fastapi", "pydantic", "starlette", "uvicorn", "httpx", "pytest"):
print(f"{name}=={version(name)}")
PY
)"
printf '%s\n' "${versions}" | sed 's/^/ /'
for pin in "fastapi==0.139.2" "uvicorn==0.51.0" "httpx==0.28.1" "pytest==9.1.1"; do
case "${versions}" in
*"${pin}"*) check "installed ${pin} matches requirements/requirements.txt" "yes" ;;
*) check "installed ${pin} matches requirements/requirements.txt" "no" ;;
esac
done
# pydantic is not requested directly — it arrives because FastAPI depends on
# it — so this check confirms the pin matches what actually got installed
# rather than what somebody assumed.
case "${versions}" in
*"pydantic==2.13.4"*) check "pydantic 2.13.4 arrived as a FastAPI dependency" "yes" ;;
*) check "pydantic 2.13.4 arrived as a FastAPI dependency" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "2. The reference suite passes"
# --------------------------------------------------------------------------
examples_out="$(cd "${lab_dir}" && "${pytest_bin}" examples -q 2>&1)"
examples_exit=$?
if [ "${examples_exit}" -eq 0 ]; then
check "pytest examples exits 0" "yes"
else
check "pytest examples exits 0 (got ${examples_exit})" "no"
printf '%s\n' "${examples_out}" | tail -30
fi
case "${examples_out}" in
*"42 passed"*) check "pytest examples reports 42 passed" "yes" ;;
*) check "pytest examples reports 42 passed (got: $(printf '%s' "${examples_out}" | tail -1))" "no" ;;
esac
# The named assertions the lesson promises really exist and really run.
collected="$(cd "${lab_dir}" && "${pytest_bin}" examples --collect-only -q 2>&1)"
for test_id in \
"test_api.py::test_a_valid_create_returns_201" \
"test_api.py::test_an_empty_title_is_422_and_the_detail_names_the_field" \
"test_api.py::test_a_missing_bookmark_is_404_with_a_detail_and_no_traceback" \
"test_api.py::test_delete_returns_204_with_an_empty_body" \
"test_api.py::test_the_response_does_not_contain_the_internal_owner_token" \
"test_api.py::test_the_openapi_schema_contains_every_declared_path" \
"test_api.py::test_no_file_was_written_anywhere_near_this_lab" \
"test_type_demo.py::test_an_unhandled_exception_becomes_a_500_with_no_traceback"
do
case "${collected}" in
*"${test_id}"*) check "collection finds ${test_id}" "yes" ;;
*) check "collection finds ${test_id}" "no" ;;
esac
done
# --------------------------------------------------------------------------
echo
echo "3. The seven claims, verified independently of pytest"
# --------------------------------------------------------------------------
# This block drives the same application through TestClient from a plain
# script, so the checks do not depend on the lab's own test file being
# correct. It prints one PASS/FAIL line per claim and exits non-zero if any
# claim fails.
claims_out="$(cd "${lab_dir}/examples" && "${python_bin}" - <<'PY' 2>&1
import itertools
import json
import sys
import warnings
from datetime import UTC, datetime
from pathlib import Path
warnings.filterwarnings(
"ignore", message="Using `httpx` with `starlette.testclient` is deprecated"
)
sys.path.insert(0, str(Path.cwd()))
import api
from fastapi.testclient import TestClient
from storage import InMemoryStorage
store = InMemoryStorage()
counter = itertools.count(1)
api.app.dependency_overrides[api.get_storage] = lambda: store
api.app.dependency_overrides[api.get_now] = lambda: datetime(2026, 7, 19, 9, 30, tzinfo=UTC)
api.app.dependency_overrides[api.get_new_id] = lambda: f"bm-{next(counter):04d}"
api.app.dependency_overrides[api.get_owner_token] = lambda: "secret-owner-token"
client = TestClient(api.app)
VALID = {
"title": "The FastAPI documentation",
"url": "https://fastapi.tiangolo.com/",
"tags": ["python", "web"],
}
failed = 0
def claim(label, ok):
global failed
print(("PASS " if ok else "FAIL ") + label)
if not ok:
failed += 1
created = client.post("/bookmarks", json=VALID)
body = created.json()
claim("create returns 201", created.status_code == 201)
claim(
"create returns exactly the response-model shape",
set(body) == {"id", "title", "url", "tags", "created_at"},
)
claim("create sets a Location header", created.headers.get("location") == "/bookmarks/bm-0001")
bad = client.post("/bookmarks", json={**VALID, "title": ""})
detail = bad.json().get("detail", [{}])
claim("an invalid body returns 422", bad.status_code == 422)
claim(
"the 422 detail names the offending field",
detail[0].get("loc") == ["body", "title"] and detail[0].get("type") == "string_too_short",
)
missing = client.get("/bookmarks/no-such-id")
claim("a missing resource returns 404", missing.status_code == 404)
claim(
"the 404 body is a detail string, not a traceback",
isinstance(missing.json().get("detail"), str)
and "Traceback" not in missing.text
and "storage.py" not in missing.text,
)
deleted = client.delete("/bookmarks/bm-0001")
claim("delete returns 204", deleted.status_code == 204)
claim("the 204 body is empty", deleted.content == b"")
claim("the resource is gone afterwards", client.get("/bookmarks/bm-0001").status_code == 404)
again = client.post("/bookmarks", json=VALID)
new_id = again.json()["id"]
stored = store.get(new_id)
claim("the server really did store the internal field", stored.owner_token == "secret-owner-token")
claim("the response body has no owner_token key", "owner_token" not in again.json())
claim("the response text does not contain the secret", "secret-owner-token" not in again.text)
schema = client.get("/openapi.json").json()
claim("the OpenAPI schema is generated", schema.get("openapi", "").startswith("3."))
claim(
"the schema contains every declared path",
set(schema["paths"]) == {"/health", "/bookmarks", "/bookmarks/{bookmark_id}"},
)
claim(
"the schema records the chosen status codes",
"201" in schema["paths"]["/bookmarks"]["post"]["responses"]
and "204" in schema["paths"]["/bookmarks/{bookmark_id}"]["delete"]["responses"],
)
claim(
"the public output schema has no owner_token",
"owner_token" not in schema["components"]["schemas"]["BookmarkOut"]["properties"],
)
claim("the injected storage holds the records", [b.id for b in store.all()] == [new_id])
claim(
"no bookmarks.json was written anywhere in the lab",
not any(Path.cwd().parent.rglob("bookmarks.json")),
)
sys.exit(1 if failed else 0)
PY
)"
claims_exit=$?
printf '%s\n' "${claims_out}" | sed 's/^/ /'
if [ "${claims_exit}" -eq 0 ]; then
check "all independent claim checks passed" "yes"
else
check "all independent claim checks passed (script exit ${claims_exit})" "no"
fi
claim_count="$(printf '%s\n' "${claims_out}" | grep -c '^PASS ' || true)"
if [ "${claim_count}" -eq 19 ]; then
check "all 19 claims were actually evaluated" "yes"
else
check "all 19 claims were actually evaluated (counted ${claim_count})" "no"
fi
# --------------------------------------------------------------------------
echo
echo "4. The demo script runs and prints what the lesson quotes"
# --------------------------------------------------------------------------
demo_out="$(cd "${lab_dir}" && "${python_bin}" examples/demo.py 2>&1)"
demo_exit=$?
if [ "${demo_exit}" -eq 0 ]; then
check "examples/demo.py exits 0" "yes"
else
check "examples/demo.py exits 0 (got ${demo_exit})" "no"
fi
for fragment in \
'Location: /bookmarks/bm-0001' \
'"type": "string_too_short"' \
'"type": "url_parsing"' \
'"type": "extra_forbidden"' \
'"detail": "No bookmark with id '"'"'nope'"'"'"' \
'owner_token in the response body? False' \
'openapi version : 3.1.0'
do
case "${demo_out}" in
*"${fragment}"*) check "demo output contains: ${fragment}" "yes" ;;
*) check "demo output contains: ${fragment}" "no" ;;
esac
done
# --------------------------------------------------------------------------
echo
echo "5. The starter is runnable before you start, and honest about it"
# --------------------------------------------------------------------------
starter_out="$(cd "${lab_dir}" && "${pytest_bin}" starter -q 2>&1)"
starter_exit=$?
if [ "${starter_exit}" -eq 0 ]; then
check "pytest starter exits 0 with the exercises unfinished" "yes"
else
check "pytest starter exits 0 with the exercises unfinished (got ${starter_exit})" "no"
fi
case "${starter_out}" in
*"1 passed, 9 skipped"*) check "the starter has 1 worked test and 9 skipped exercises" "yes" ;;
*) check "the starter has 1 worked test and 9 skipped exercises" "no" ;;
esac
schema_out="$(cd "${lab_dir}" && "${python_bin}" starter/schema.py 2>&1)"
schema_exit=$?
if [ "${schema_exit}" -eq 0 ]; then
check "starter/schema.py exits 0" "yes"
else
check "starter/schema.py exits 0 (got ${schema_exit})" "no"
fi
case "${schema_out}" in
*"No BookmarkOut schema yet"*)
check "starter/schema.py reports the unfinished state honestly" "yes" ;;
*) check "starter/schema.py reports the unfinished state honestly" "no" ;;
esac
# The starter really is the naive version the exercises fix: one shared model
# that carries the internal field into the public schema.
case "${schema_out}" in
*"Component schemas: Bookmark,"*)
check "the starter still has one shared model (Exercise 2 splits it)" "yes" ;;
*) check "the starter still has one shared model (Exercise 2 splits it)" "no" ;;
esac
# --------------------------------------------------------------------------
echo
echo "6. The starter suite is not vacuous — it fails on a broken app"
# --------------------------------------------------------------------------
# Copy the reference implementation in as `app.py`, un-skip everything, and
# demand that the starter's own suite goes green. A suite that cannot tell a
# finished application from an unfinished one is worth nothing.
work="$(mktemp -d "${TMPDIR:-/tmp}/day082-solved.XXXXXX")"
cp "${lab_dir}/starter/test_app.py" "${lab_dir}/starter/conftest.py" \
"${lab_dir}/starter/pytest.ini" "${work}/"
cp "${lab_dir}/examples/models.py" "${lab_dir}/examples/storage.py" "${work}/"
cp "${lab_dir}/examples/api.py" "${work}/app.py"
# The starter's conftest clears a global the finished app does not have.
"${python_bin}" - "${work}/conftest.py" <<'PY'
import sys
from pathlib import Path
path = Path(sys.argv[1])
text = path.read_text(encoding="utf-8")
text = text.replace(" app.BOOKMARKS.clear()", " getattr(app, 'BOOKMARKS', {}).clear()")
path.write_text(text, encoding="utf-8")
PY
"${python_bin}" - "${work}/test_app.py" <<'PY'
import re
import sys
from pathlib import Path
path = Path(sys.argv[1])
lines = [
line
for line in path.read_text(encoding="utf-8").splitlines(keepends=True)
if not re.match(r"^@pytest\.mark\.skip", line)
]
path.write_text("".join(lines), encoding="utf-8")
PY
solved_out="$(cd "${work}" && "${pytest_bin}" . -q 2>&1)"
solved_exit=$?
if [ "${solved_exit}" -eq 0 ]; then
check "the starter suite goes fully green against the finished application" "yes"
else
check "the starter suite goes fully green against the finished application (exit ${solved_exit})" "no"
printf '%s\n' "${solved_out}" | tail -20
fi
case "${solved_out}" in
*"10 passed"*) check "all 10 starter tests pass once the exercises are done" "yes" ;;
*) check "all 10 starter tests pass once the exercises are done" "no" ;;
esac
# Now break exactly one thing — remove the response_model that stops the leak
# — and demand that the leak check FAILS. This is the check that proves the
# leak assertion is doing work.
sed -i.bak 's/ response_model=BookmarkOut,//' "${work}/app.py"
rm -f "${work}/app.py.bak"
leak_out="$(cd "${work}" && "${pytest_bin}" . -q 2>&1)"
leak_exit=$?
if [ "${leak_exit}" -ne 0 ]; then
check "removing response_model makes the suite FAIL (exit ${leak_exit}, not 0)" "yes"
else
check "removing response_model makes the suite FAIL — it did not, so the leak check is vacuous" "no"
fi
case "${leak_out}" in
*"test_the_response_never_contains_the_internal_owner_token"*)
check "the failing run names the leak check by test id" "yes" ;;
*) check "the failing run names the leak check by test id" "no" ;;
esac
rm -rf "${work}"
# --------------------------------------------------------------------------
echo
echo "7. Nothing opened a socket — and the guard that says so is real"
# --------------------------------------------------------------------------
# The reference suite ran behind an autouse guard that raises on any outbound
# connection. Prove the guard is armed by writing one test that deliberately
# trips it, and demanding a failure.
guard="$(mktemp -d "${TMPDIR:-/tmp}/day082-guard.XXXXXX")"
cp "${lab_dir}/examples/conftest.py" "${lab_dir}/examples/models.py" \
"${lab_dir}/examples/storage.py" "${lab_dir}/examples/api.py" \
"${lab_dir}/examples/pytest.ini" "${guard}/"
cat > "${guard}/test_guard.py" <<'PY'
"""One test that deliberately reaches for the network. It must fail."""
import socket
def test_this_one_should_be_stopped_by_the_guard():
socket.create_connection(("127.0.0.1", 9), timeout=0.1)
PY
guard_out="$(cd "${guard}" && "${pytest_bin}" . -q 2>&1)"
guard_exit=$?
if [ "${guard_exit}" -ne 0 ]; then
check "a test that tries to connect is stopped (exit ${guard_exit}, not 0)" "yes"
else
check "a test that tries to connect is stopped — it was not, so the guard is decorative" "no"
fi
case "${guard_out}" in
*"NetworkAccessAttempted"*)
check "the guard raises NetworkAccessAttempted naming the address" "yes" ;;
*) check "the guard raises NetworkAccessAttempted naming the address" "no" ;;
esac
rm -rf "${guard}"
# The guard was armed for the reference run too, and that run was green.
case "${examples_out}" in
*NetworkAccessAttempted*)
check "no test in the reference suite tripped the network guard" "no" ;;
*) check "no test in the reference suite tripped the network guard" "yes" ;;
esac
# Belt and braces: no lab source asks for a real network client or binds a port.
if grep -rqE 'requests\.get|urlopen|uvicorn\.run|\.bind\(|httpx\.(get|post|Client)' \
"${lab_dir}/examples" "${lab_dir}/starter" 2>/dev/null; then
check "no lab source opens a connection or binds a port" "no"
else
check "no lab source opens a connection or binds a port" "yes"
fi
# --------------------------------------------------------------------------
echo
echo "8. Nothing was written to disk"
# --------------------------------------------------------------------------
if find "${lab_dir}" -name 'bookmarks.json' -print -quit 2>/dev/null | grep -q .; then
check "no bookmarks.json anywhere under the lab after a full run" "no"
else
check "no bookmarks.json anywhere under the lab after a full run" "yes"
fi
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting — Day 082 lab
Every symptom below was produced on the authoring machine while building this lab, or is a mistake the exercises make easy to make. Nothing here is hypothetical filler.
ModuleNotFoundError: No module named 'fastapi'
The python3 or pytest you ran is not the one you installed into.
cd labs/sections/programming-with-python/day-082-a-first-web-api-with-fastapi
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest examples
tests/run_tests.sh resolves pytest itself — an explicit PYTEST= first,
then .venv/bin/pytest, then PATH — and then uses the python3 sitting
beside it. If FastAPI is not importable from that interpreter it stops with
install instructions rather than skipping checks.
ModuleNotFoundError: No module named 'api' (or models, or storage)
The lab's modules import each other by bare name (from models import ...),
which requires their own directory to be on sys.path. Both conftest.py
files put it there, so running pytest works from anywhere. Running a file
directly does not, which is why examples/demo.py and starter/schema.py
each insert their directory themselves. If you copy a snippet into a new
file of your own, copy that sys.path.insert line too — or run pytest.
StarletteDeprecationWarning: Using 'httpx' with 'starlette.testclient' is deprecated; install 'httpx2' instead
Real, expected, and harmless with the pinned versions. Starlette 1.3.1 is
signalling that a future release will prefer the httpx2 package over
httpx 0.28.1. Everything works today — all 42 reference tests pass — so
the two pytest.ini files filter this one message so the captured output
stays readable. It is filtered by its exact text, not by silencing warnings
in general, so a different deprecation would still reach you.
If you would rather see it, delete the filterwarnings block from
examples/pytest.ini, or run pytest examples -W always.
422 Unprocessable Content when you expected a 200
This is validation working. Read the body — it tells you exactly what is wrong and where:
{"detail": [{"type": "string_too_short", "loc": ["body", "title"],
"msg": "String should have at least 1 character", "input": ""}]}
loc is the path to the offending value: ["body", "title"] means the
title field of the request body; ["query", "limit"] means the limit
query parameter; ["path", "item_id"] means the value in the URL itself.
The three most common causes in this lab:
- an empty
title(minimum length 1); - a
urlthat is not an absolute URL —"fastapi.tiangolo.com"is not, and"https://fastapi.tiangolo.com/"is; - a field the create model does not declare.
extra="forbid"turns a misspelledtitelor a hopefulidinto a 422 rather than dropping it silently, which is a feature, not an obstacle.
AssertionError comparing a URL to a string
HttpUrl normalises what it parses. Send https://fastapi.tiangolo.com and
the response carries https://fastapi.tiangolo.com/ — a trailing slash was
added, because that is the canonical form of a URL with an empty path. Assert
against the normalised value, or send the normalised value in the first
place. This is validation doing its second job: not just rejecting bad input
but canonicalising good input, so downstream code sees one spelling.
KeyError instead of a 404, and a 500 in the response
That is the unfinished starter/app.py, Exercise 5. BOOKMARKS[bookmark_id]
raises KeyError when the id is unknown; nothing catches it; the server
turns any uncaught exception into a 500. Raise
HTTPException(status_code=404, detail=...) instead. A missing thing is an
answer, not a failure.
A 500 with the body Internal Server Error and nothing else
Correct and deliberate: a client must never receive a traceback, because a
traceback names your files, your line numbers and your local variables. The
traceback is printed on the server side, where you can read it. Under
TestClient the default is to re-raise the exception into your test instead,
which is more useful when debugging;
examples/test_type_demo.py::test_an_unhandled_exception_becomes_a_500_with_no_traceback
uses TestClient(app, raise_server_exceptions=False) to see what a real
client would see.
assert response.status_code == 200 fails with 307
You asked for a path whose trailing slash does not match the declared route.
The routes here are /bookmarks and /bookmarks/{bookmark_id}; requesting
/bookmarks/ redirects. TestClient follows redirects by default, so you
normally never notice — until you pass follow_redirects=False.
The response created_at changes on every run
Expected, until Exercise 7. The unfinished app calls datetime.now() inside
the handler, so the value is different every time and nothing can assert on
it. Inject the clock — Annotated[datetime, Depends(get_now)] — and a test
overrides it with a fixed value. That is Day 074's argument applied to time
rather than to the network.
AssertionError: the real JsonFileStorage was constructed in a test
Your override did not take effect, so a handler asked for storage and got the
production one. Check that the key in app.dependency_overrides is the
dependency function object (api.get_storage), not its name and not a
copy imported under a different alias.
NetworkAccessAttempted: a test tried to connect to ...
Something in the run tried to open a socket. That is the guard in
conftest.py doing exactly its job. Nothing in this lab should ever trip it;
if your own code does, you have reached for a real service instead of
injecting a fake.
uvicorn: command not found, or the server starts and nothing responds
uvicorn lives in the lab's environment, so run .venv/bin/uvicorn. From
the lab directory:
.venv/bin/uvicorn api:app --reload --host 127.0.0.1 --port 8123 --app-dir examples
--app-dir is what puts examples/ on the import path so api:app resolves.
If port 8123 is already taken, pick another number — the port is yours to
choose, and nothing in the tests depends on it.
Windows
Use WSL and follow the Linux instructions. Native Windows works too:
substitute python for python3 and .venv\Scripts\ for .venv/bin/.
Nothing in this lab depends on path separators, and no test binds a port, so
firewall prompts never appear.
Security notes
Security notes — Day 082 lab
Writing a server is the first thing in this course that, run for real, would accept input from a stranger. That changes what "careful" means, so this file is longer than most.
What this lab does and does not do to your machine
- Nothing here opens a network connection. Every request goes through
TestClient, which hands the request object straight to the application in the same process. No port is bound, so no firewall prompt appears and no other program on your network can reach anything. - The reference suite proves that rather than promising it. An autouse
fixture in
examples/conftest.pyreplacessocket.socket.connectandsocket.create_connectionwith functions that raise. Section 7 oftests/run_tests.shwrites a throwaway test that deliberately tries to connect and asserts that it fails — so the guard is demonstrably armed and not decorative. - Nothing is written to disk during the tests. Storage is injected, and
the tests inject an in-memory dictionary. Section 8 of the harness searches
the whole lab directory for a
bookmarks.jsonafterwards and fails if one exists. - No credentials, keys or accounts are involved. No signup, no token, no paid service.
- One command in the README does bind a port —
uvicorn ... --host 127.0.0.1— and it is entirely optional.127.0.0.1means the loopback interface, so even then the server is reachable only from your own machine. Binding0.0.0.0instead would expose it to your whole network; do not do that without meaning to.
The five security lessons this lab is actually teaching
1. Validation is not authorization
Every request in this API is validated, and every request is also completely
unauthenticated. Those are different questions. Validation asks is this
well-formed? Authorization asks is this caller allowed? A perfectly valid
DELETE /bookmarks/bm-0001 from a stranger is still a stranger deleting your
bookmark. This lab deliberately stops at validation so the distinction stays
visible; a real API adds an authentication scheme and a permission check per
route, and neither comes free with pydantic.
2. Never trust a client-supplied identifier
BookmarkCreate has no id field, and extra="forbid" means sending one is
a 422 rather than a shrug. The id, the creation time and the internal token
are all generated on the server. An API that accepts a client's id lets a
caller overwrite somebody else's record by guessing a number, and an API that
accepts a client's created_at lets a caller backdate history.
examples/test_api.py::test_the_id_is_server_generated_and_a_client_cannot_choose_it
holds that line.
3. Declare what you return, not just what you accept
StoredBookmark carries owner_token. BookmarkOut does not.
response_model=BookmarkOut on each handler is what stands between the two,
and it is one line — which is exactly why it is easy to forget. Three tests
assert the field's absence, by key and by raw substring, on every route
that returns a bookmark, plus one on the generated schema. Section 6 of the
harness deletes that one line from a copy of the application and demands that
the suite go red, because an assertion that cannot fail is not protecting
anything.
The general shape of this bug is the most common data leak in real APIs: a handler returns the database row, and the row has a password hash, an internal note, another user's email, or a flag that reveals your schema.
4. A traceback is never a response
HTTPException turns a known negative answer into a small JSON body with a
detail string. An unknown failure — a bug — becomes a 500 whose body is
the five words Internal Server Error, while the traceback goes to the
server log. That asymmetry is deliberate. A traceback names your file paths,
your line numbers, your framework versions and the values of your local
variables, and every one of those is a gift to somebody probing your service.
examples/test_type_demo.py asserts that a deliberate ZeroDivisionError
produces a 500 containing no filename, no exception name and no traceback.
Be careful with detail strings too: this lab echoes the requested id back
(No bookmark with id 'nope'), which is fine for an id the caller just sent.
Echoing something the caller did not send — a filename, a query, an
internal message — is how a helpful error message becomes a disclosure.
5. Secrets come from the environment, and CORS is not a security feature
get_storage reads its path from os.environ, not from a literal in the
source. The same rule Day 078 stated for API tokens applies to every secret a
server holds: a value in source is a value in version control, in every clone
and in every backup.
Cross-Origin Resource Sharing deserves one honest paragraph, because it is
routinely misunderstood. CORS is a browser mechanism: a page loaded from one
origin may not read a response from another origin unless that other origin's
headers permit it. It protects the user's browser session, and it does
nothing whatsoever against curl, a script, or any non-browser client.
Setting allow_origins=["*"] to make a frontend error go away is a decision
about which web pages may read your data, not a decision about security in
general — and if your API relies on cookies, it is a decision with real
consequences. This lab adds no CORS middleware at all, because it has no
browser frontend, and adding middleware you do not need is its own risk.
Things this lab deliberately does not have
Naming them is more honest than implying the list is complete:
- No authentication and no authorization. Anyone who can reach the server can do anything.
- No rate limiting. A caller can issue as many requests as they like.
- No request-size limit beyond what the ASGI server imposes by default.
- No HTTPS.
uvicornon loopback speaks plain HTTP; a real deployment terminates TLS in front of the application. - No audit log. Nothing records who changed what.
- No concurrency control.
JsonFileStoragerewrites the whole file, so two simultaneous writes can lose one. Week 13's database work is the answer.
Every one of those is a normal thing to add later, and none of them is something pydantic, FastAPI or a passing test suite gives you for free.
Cleanup
The tests leave nothing behind. If you ran the server by hand, stop it with
Ctrl-C and delete any bookmarks.json it created:
rm -f bookmarks.json
rm -rf .venv examples/.pytest_cache starter/.pytest_cache