What a Framework Does for You · practice
Let a Model Check the Body
In Chapter 5 two reading_server.py. body_error looked at the request, and book_error at the parsed data:
| Function | Refuses when | Status |
|---|---|---|
body_error(request) | content-type is not application/json | 415 |
body_error(request) | the body is not | 400 |
book_error(data) | not a JSON | 422 |
book_error(data) | title or author missing | 422 |
book_error(data) | title or author not a non-empty | 422 |
handle called them in that order, and create_book and replace_book ran only when both returned None. The app does both jobs with the model you read in Lesson 1, BookIn, and one annotation in each def line: book: BookIn.
The model checks first
When POST /books arrives, BookIn, all before create_book runs. Your function is called only with a body that passed, and it receives that body as a BookIn object. The object gives its checked values as attributes, written with a dot: book.title and book.author. It is not a book["title"] raises
On the versions this course uses, the model refuses:
a body that is not a JSON object, or that lacks
titleorauthor;a field that is not a string:
7andtrueare refused, never turned into"7"or"true";a field with no character other than spaces, such as
""or" ". That is whatpattern=r"\S"asks for: one character, anywhere, that is not a space, a tab, a line break, or another kind of whitespace.
It accepts the rest as it is. " Ærø " keeps its spaces, "7" is a string, and a 3000-character title has no limit to break. Fields the model does not name, such as "id": 99, are dropped before your function sees the body, so a client cannot choose a book’s id.
A POST body is {"title": "Dune"}. When does create_book run?
Read FastAPI’s 422
For that body your Chapter 5 server answered {"error": "author is missing"}. The app answers 422 with this body:
{"detail":[{"type":"missing","loc":["body","author"],"msg":"Field required","input":{"title":"Dune"}}]}
detail is a type names the rule that failed (missing here; string_type for a number where a string belongs; string_pattern_mismatch for a blank title), loc says where (["body", "author"], the body’s author field), msg says it in words, and input echoes the
Where the framework decides differently
Most bad bodies get the status they got from your server. These do not:
| Request | Chapter 5 | The FastAPI app |
|---|---|---|
a valid JSON book labeled text/plain | 415 | 422 |
a valid JSON book with no content-type | 415 | 422 |
| JSON that does not parse | 400 | 422 |
| bytes that are not UTF-8 | 400 | 400, {"detail":"There was an error parsing the body"} |
a book labeled application/merge-patch+json | 415 | accepted, 201 |
PUT /books/9999, no such book, with a broken body | 404 | 422 |
FastAPI parses a body as JSON only when its content-type says JSON: application/json, or an application/ type whose subtype ends in +json, such as application/merge-patch+json. Any other label, or none, leaves the body as text, and the model refuses text because it is not an object; the 422 says the model expected an object. There is no separate 415. Broken JSON is a 422 too, with the type json_invalid. Bytes that cannot be decoded as UTF-8 are the one body problem FastAPI answers with 400, and its detail there is a plain string.
The PUT row is about order. Your server looked up the id before it read the body, so an unknown id was a 404 whatever the body held. FastAPI checks the body before it calls replace_book, so a bad body is a 422 before your function can look up anything. From Chapter 7 your server also asked for a token before either; this app has no tokens.
Two Chapter 5 statuses never appear here. 411 and 413 were framing work in your read_body; TestClient always sends a length, and the app sets no size limit.
A valid JSON book is labeled text/plain. Chapter 5 answered 415. What does the app answer?
PUT /books/9999, where no book 9999 exists, carries a broken body. Chapter 5 answered 404. What does the app answer?
201 and location
The line above create_book is @app.post("/books", status_code=201): every value the function returns goes out with status 201. The def line also asks for response: Response. FastAPI passes in a response object, and every header you set on it is copied into the reply:
response.headers["location"] = f"/books/{book_id}"
Return the stored book, and FastAPI sends it as the JSON body of the 201, with your location. replace_book needs neither: a replaced book is a plain 200.
Your task
Write the bodies of create_book and replace_book in reading_app.py:
create_book(book, response)takes the id fromSTATE["next_id"]and adds one toSTATE["next_id"], stores{"id": ..., "title": book.title, "author": book.author}inSTATE["books"]under that id, setslocationto/books/and the id, and returns the stored book.replace_book(book_id, book)raisesHTTPException(status_code=404, detail="no such book")for an idSTATE["books"]does not hold. Otherwise it stores the book underbook_id, the id from the, and returns it.
Write no checks of your own on title or author: the model has done that, and a rule of yours that refused something the model accepts, such as a title with spaces around it, would make the app disagree with its own model. To try one request in the Shell tab, with the test client’s import warning turned off:
python -W ignore -c "from fastapi.testclient import TestClient; import reading_app; r = TestClient(reading_app.app).post('/books', json={'title': 'Piranesi', 'author': 'Susanna Clarke'}); print(r.status_code, r.headers['location'], r.json())"
It prints 201 /books/3 {'id': 3, 'title': 'Piranesi', 'author': 'Susanna Clarke'}. Then press Check my work. So far no byte of your app’s replies has crossed a
Task
Write the bodies of create_book and replace_book in reading_app.py, under the @app.post and @app.put lines that are already there.
create_book(book, response)takes the new id fromSTATE["next_id"]and adds one toSTATE["next_id"]. It stores{"id": ..., "title": book.title, "author": book.author}inSTATE["books"]under that id, setsresponse.headers["location"]to/books/followed by the id, and returns the stored book. The suppliedstatus_code=201makes the reply a 201.replace_book(book_id, book)raisesHTTPException(status_code=404, detail="no such book")whenSTATE["books"]has no book with that id. Otherwise it stores{"id": book_id, "title": book.title, "author": book.author}underbook_id, the id from the, and returns it.
book is a BookIn that book.title and book.author, and add no checks of your own on them. Keep every @app line, every def line, and BookIn as they came.
In the python -m pytest -q still runs your tests, and the one-line python -W ignore -c ... command in the lesson sends one POST.
Then press Check my work. It fills STATE through reset_state with books you have not seen and a next_id of 41, then sends POST and PUT requests into the app in this process with TestClient, with titles and authors you have not seen. A new book must get 201, a location naming its id, and the stored book in the body, and next_id must move on; a PUT must keep the id from the URL; a PUT to an unknown id must get 404. Bodies the model refuses must get FastAPI’s 422, or its 400 for bytes that are not BookIn