0%

Request Bodies and a JSON API · practice

Say What the Body Is

read_body now hands handle the exact bytes the client sent. Bytes alone do not say what they are: {"title": "Dune"} could be , a line of text, or the start of something else. The sender says what they are in the Content-Type header, the same header your server already puts on every reply it sends, content-type: application/json. Before your server treats a body as JSON, it checks two things: that the client labeled it JSON, and that the bytes really are JSON.

The media type

A Content-Type starts with a media type, type/subtype, and may add after a semicolon:

Content-Type: application/json
Content-Type: Application/JSON; charset=utf-8

Both lines label the body application/json. Media type names are case-insensitive, and charset=utf-8 is a parameter, not part of the type. So the media type is the part before the first ;, with spaces stripped, compared in lowercase.

application/json defines no charset parameter at all: JSON exchanged between systems is (RFC 8259, section 8.1). Clients add charset=utf-8 anyway, and it does no harm, so this server ignores every parameter and always decodes JSON as UTF-8.

What does this print?

print("Application/JSON; charset=UTF-8".split(";")[0].strip().lower())

Compare the whole media type, not a piece of it. "json" in value also accepts application/jsonx, and startswith("application/json") accepts application/json-patch+json. That last one is a real media type: ending in +json (RFC 6839) are JSON with extra rules of their own. This server accepts exactly application/json and refuses the rest. That is its choice, written in SERVICE_CONTRACT.md, not a rule of .

In the request , header names are lowercase and each holds a of values, so the label is request["headers"]["content-type"][0]. A request may have no label at all; request["headers"].get("content-type", [""]) gives a list holding an empty then, which matches nothing.

415 or 400

Two different things can be wrong, and each has its own status:

  • The label is wrong or missing. The server does not accept that media type, whatever the bytes are: 415 Unsupported Media Type. It never looks at the bytes.

  • The label is right, the bytes are not. The body is not UTF-8, or it is not valid JSON: 400 Bad Request, a malformed request.

The two statuses tell the client different things to fix. After a 415 it sends a format this server accepts, labeled as such; after a 400 it fixes the bytes it sends. A server that answers both with 400 leaves the client guessing which one went wrong.

json_body(request) is already in reading_server.py: it returns json.loads(request["body"].decode("utf-8")). Two steps can fail inside it. decode raises UnicodeDecodeError on bytes that are not UTF-8, and json.loads raises json.JSONDecodeError on text that is not JSON. Both are subclasses of , so one except ValueError: catches both:

Try it

That prints {'title': 'Dune'}, then JSONDecodeError, then UnicodeDecodeError. Keep the except that narrow. except Exception: would also turn a mistake in your own code, such as a misspelled key, into a 400 that blames the client.

Do not decode as Latin-1 to make the error go away. Every byte is a Latin-1 character, so that decode never fails, and bytes that are not UTF-8 would be read as the wrong text instead of being refused.

A body is valid JSON but labeled text/plain. Which status?

Your task

Write body_error(request) in reading_server.py:

  1. Take the media type from the request’s content-type, as above. If it is not application/json, return a 415 response built with json_error.

  2. Call json_body(request) inside try. If it raises ValueError, return a 400 response.

  3. Otherwise return None.

Like read_body, it returns None or the response to send. Every error reply comes from json_error, so it carries its own content-type: application/json, even when it refuses the client’s label.

No route calls body_error until Lesson 4, so try it from the Shell tab. make_request in tests/test_server.py builds a request dict with a JSON body, a content-type, and a length; the script changes the label of one request and the bytes of another:

python - <<'EOF'
import reading_server
from tests.test_server import make_request

book = {"title": "Dune", "author": "Frank Herbert"}
print(reading_server.body_error(make_request("POST", "/books", book)))
labeled = make_request("POST", "/books", book)
labeled["headers"]["content-type"] = ["text/plain"]
print(reading_server.body_error(labeled)["status"])
broken = make_request("POST", "/books", book)
broken["body"] = b'{"title": '
print(reading_server.body_error(broken)["status"])
EOF

It prints None, 415, and 400.

Then press Check my work. The next lesson steps back from code to decide where a new book goes and what each write answers.

Task

Check what a request body claims to be before treating it as .

In reading_server.py, write body_error(request):

  1. Take the media type of the request’s content-type: the part of its first before any ;, stripped of spaces and lowercased. A request with no content-type has none. If the media type is not exactly application/json, return a 415 response.

  2. Call the supplied json_body(request) inside try. If it raises , return a 400 response.

  3. Otherwise return None.

Build both error replies with json_error and a sentence of your own. Catch ValueError only, not Exception.

In the , the script in the lesson prints what body_error returns for three requests built with make_request from tests/test_server.py.

Then press Check my work. It calls body_error with labels and bodies you have not seen: application/json in other casings and with , labels that only contain json, no label at all, bytes that are not , and text that is not JSON. It checks that the label is judged before the bytes, that an error from your own code is not turned into a 400, and that a header name in unusual casing still reaches your through the adapter. Nothing from your terminal is graded.