0%

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 that ends in 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 would get further and fail on 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:

CheckFails whenStatusWho checks
framingno length on POST or PUT; a malformed length or a short body; too long411; 400; 413read_body
route and no such path, or the path does not take this method404, 405handle
book idno book has that id404handle
media typecontent-type is not application/json415body_error
syntaxthe bytes are not JSON400body_error
shape and valuesnot an with non-empty title and author422book_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:

The checks run from framing down to shape and values, the first one that fails decides the status, and only a request that passes them all reaches the write.

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 is a , which is what a JSON object becomes; 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:

  1. Write book_error(data): return a 422 response, built with json_error, when data is not a dict, when title or author is missing, or when either is not a string or is empty once stripped. Otherwise return None.

  2. Write book_request_error(request): return body_error(request) if it is not None; otherwise return book_error(json_body(request)).

  3. In handle, call book_request_error before create_book and before replace_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:

  1. Write book_error(data). Return a 422 response when data is not a , when it has no title or no author, or when either of them is not a or is empty once stripped of spaces. Otherwise return None.

  2. Write book_request_error(request). Return what body_error(request) returns when that is not None; otherwise return book_error(json_body(request)).

  3. In handle, call book_request_error before create_book and before replace_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 , then the book id, then the body.

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: , numbers, 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.