Request Bodies and a JSON API · practice
Reject Bad Requests by Cause
Your writes work for valid books, and only for those. Send the helper’s bad requests and watch what happens:
python pl_http.py bad-requests
The first request is POST /books with the body title=Dune, labeled text/plain. handle calls create_book, which calls json_body, which raises. The reply line in the transcript reads (no bytes: the server closed the connection without answering), and the log block holds a json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0). When handle raises, http.server prints the traceback and closes the connection without a reply, so the client learns nothing. pl_http stops sending after that request and says so.
A data["title"]; a book with an empty title would be stored. Each bad request needs a status that names what is wrong, and none of them may change the state.
Status by cause
SERVICE_CONTRACT.md lists these checks, and the first one that fails decides the reply. Here they are in the order the server runs them:
| Check | Fails when | Status | Who checks |
|---|---|---|---|
| framing | no length on POST or PUT; a malformed length or a short body; too long | 411; 400; 413 | read_body |
| route and | no such path, or the path does not take this method | 404, 405 | handle |
| book id | no book has that id | 404 | handle |
| media type | content-type is not application/json | 415 | body_error |
| syntax | the bytes are not | 400 | body_error |
| shape and values | not an title and author | 422 | book_error |
A request that passes every row reaches the write and gets 201, 200, or 204. Two rows need a word about their place. Framing comes before the route, because read_body runs in the adapter before handle sees anything: that is why a POST /nowhere without a length gets 411, not 404. And the book id comes before the body, so a PUT to a book that does not exist is a 404, whatever its body says. In the diagram, trace a bad request down the checks until it leaves on the right:

400 or 422
A 400 and a 422 can both answer a bad body; the difference is how far the server got. 400 Bad Request says the request could not be read at all: these bytes do not parse as JSON. 422 Unprocessable Content says the server read it fine and cannot do what it says (RFC 9110, section 15.5.21): the JSON is valid, but it is a list where an object belongs, or its title is 7, or its author is three spaces.
Checking shape means checking isinstance(value, dict) asks whether a isinstance(value, str) asks whether it is a string. A title that is blank once strip() removes the spaces is as empty as "".
What does this print?
print(isinstance([1, 2], dict), isinstance({"title": 3}["title"], str))
PUT /books/7 (book 7 exists) carries {"title": "", "author": "Ana Ruiz"}, labeled application/json, with a correct length. Which status?
Check, then change
book_error(data) checks a decoded value: not a dict, a missing title or author, or one that is not a non-empty string, each gets a 422 whose error names the problem. Write each message for the person who reads the reply: author is missing tells them what to change, and bad request does not. You may check title or author first; either way, the first problem found is the one reported. book_request_error(request) runs the whole body check: body_error first, and only when that finds nothing, book_error(json_body(request)). Each returns None or the response to send, as read_body and body_error do, so handle keeps the first error it gets:
response = book_request_error(request)
if response is None:
response = create_book(request, state)
The order inside matters twice. book_error must not run on bytes that body_error refused, because json_body would raise on them. And create_book must not run before every check has passed: a book stored, or a next_id counted, before a 422 would be a change the client was told did not happen.
PUT /books/99 (no such book) carries a body labeled text/csv. Which status, by this contract's order?
Your task
In reading_server.py:
Write
book_error(data): return a 422 response, built withjson_error, whendatais not a dict, whentitleorauthoris missing, or when either is not a string or is empty once stripped. Otherwise returnNone.Write
book_request_error(request): returnbody_error(request)if it is notNone; otherwise returnbook_error(json_body(request)).In
handle, callbook_request_errorbeforecreate_bookand beforereplace_book, and send its response when it returns one.
Run python pl_http.py bad-requests again. Each request now gets its own answer: HTTP/1.0 415 Unsupported Media Type for the text/plain body, HTTP/1.0 400 Bad Request for the JSON cut short, HTTP/1.0 422 Unprocessable Content for the list, HTTP/1.0 404 Not Found for the PUT to book 99, and HTTP/1.0 411 Length Required for the POST with no length, which your read_body answered before handle ran.
Then press Check my work. Your server now refuses every bad write with a reason; the next lesson proves, with tests of your own, that the good writes keep their promises.
Task
Give every bad write its own status, and change nothing when a request is refused.
In reading_server.py:
Write
book_error(data). Return a 422 response whendatais not a, when it has no titleor noauthor, or when either of them is not aor is empty once stripped of spaces. Otherwise return None.Write
book_request_error(request). Return whatbody_error(request)returns when that is notNone; otherwise returnbook_error(json_body(request)).In
handle, callbook_request_errorbeforecreate_bookand beforereplace_book. When it returns a response, send that response and do not call the write.
Build each error reply with json_error and a sentence that names the problem. Keep the order of handle: route and
In the python pl_http.py bad-requests sends five bad requests to your server.
Then press Check my work. It calls book_error with values you have not seen: null, missing fields, numbers and blanks where strings belong, and valid books with extra fields. It calls book_request_error and handle with one cause at a time, on POST /books and on PUT to an existing book, and checks the status for each and that the state, next_id included, is exactly as before. It also sends a PUT with a wrong label to a book that does not exist, and checks that valid writes still work. Nothing from your terminal is graded.