0%

What a Framework Does for You · practice

Let a Model Check the Body

In Chapter 5 two guarded every write your server accepted, and they are still in reading_server.py. body_error looked at the request, and book_error at the parsed data:

FunctionRefuses whenStatus
body_error(request)content-type is not application/json415
body_error(request)the body is not 400
book_error(data)not a JSON 422
book_error(data)title or author missing422
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, reads the body, parses it as JSON, and checks the result against 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 , so book["title"] raises .

On the versions this course uses, the model refuses:

  • a body that is not a JSON object, or that lacks title or author;

  • a field that is not a string: 7 and true are refused, never turned into "7" or "true";

  • a field with no character other than spaces, such as "" or " ". That is what pattern=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 , with one entry per problem. In each entry, 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 that was checked. Your error was one sentence for a person. This one is data a client program can act on, field by field. Both carry 422.

Where the framework decides differently

Most bad bodies get the status they got from your server. These do not:

RequestChapter 5The FastAPI app
a valid JSON book labeled text/plain415422
a valid JSON book with no content-type415422
JSON that does not parse400422
bytes that are not UTF-8400400, {"detail":"There was an error parsing the body"}
a book labeled application/merge-patch+json415accepted, 201
PUT /books/9999, no such book, with a broken body404422

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:

  1. create_book(book, response) takes the id from STATE["next_id"] and adds one to STATE["next_id"], stores {"id": ..., "title": book.title, "author": book.author} in STATE["books"] under that id, sets location to /books/ and the id, and returns the stored book.

  2. replace_book(book_id, book) raises HTTPException(status_code=404, detail="no such book") for an id STATE["books"] does not hold. Otherwise it stores the book under book_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 ; Lesson 4 runs it under a real server for three requests.

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.

  1. create_book(book, response) takes the new id from STATE["next_id"] and adds one to STATE["next_id"]. It stores {"id": ..., "title": book.title, "author": book.author} in STATE["books"] under that id, sets response.headers["location"] to /books/ followed by the id, and returns the stored book. The supplied status_code=201 makes the reply a 201.

  2. replace_book(book_id, book) raises HTTPException(status_code=404, detail="no such book") when STATE["books"] has no book with that id. Otherwise it stores {"id": book_id, "title": book.title, "author": book.author} under book_id, the id from the , and returns it.

book is a BookIn that has already checked: read 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 , and change nothing; values the model accepts, such as a title with spaces around it, must be stored as they are. It also calls your two directly with BookIn . Nothing from your terminal is graded.