Request Bodies and a JSON API · capstone
Capstone project: Project: A Writable Reading List
Your reading list now creates, reads, replaces, and deletes books, refuses each bad request with its own status, and has tests that hold it to its method promises. One row of SERVICE_CONTRACT.md is still not true: GET /books?limit=N, the first N books. This project adds it and then checks the whole service against the contract.
What the contract asks
Open SERVICE_CONTRACT.md and read it once more as a checklist. Every row except ?limit= should already hold:
GETandHEADon/booksand/books/{id};POST /bookswith 201 andlocation;PUT /books/{id}with 200;DELETE /books/{id}with 204, no body, and nocontent-length.405 with
allow: GET, HEAD, POSTon the collection andallow: GET, HEAD, PUT, DELETEon an item, and 404 everywhere else.The error order: route and method, framing (411, 400, 413), the book id, media type (415), syntax (400), shape and values (422), with nothing stored or counted on any error.
read_bodychecks the framing beforehandleruns, so aPOST /nowherewith no length still gets 411.
Run the helper’s full scenario to see where you stand:
python pl_http.py crud
It asks for GET /books?limit=1, creates book 3, replaces it, deletes it, and asks for GET /books?limit=0. Today both GETs answer 200 with every book, content-length: 132 for the two sample books, because handle ignores the query. The three writes in between should already show HTTP/1.0 201 Created, HTTP/1.0 200 OK, and HTTP/1.0 204 No Content.
A query parameter
In Chapter 2 you built queries with urlencode and took URLs apart with urlsplit. Reading a query on the server is the reverse: urlsplit(target).query is the text after the ?, and parse_qs turns it into a dict. Each name maps to a list of values, because a name can appear more than once; parse_qs also decodes % escapes and turns + into a space, as you saw it does for forms:
That prints {'limit': ['2'], 'sort': ['title']}, then {'limit': ['1', '2']}, then {}. parse_qs is already imported at the top of reading_server.py.
The rules for limit follow the ones read_body applies to a length. No limit means no limit. A limit must appear exactly once and be made of ASCII digits, checked with isascii() and isdigit() before int, so two, -1, 1.5, and ² are refused rather than crashing your server. And it must lie from 1 to 100: limit=0 asks for nothing, and a client that wants ten thousand books in one reply should not get them. Any limit that breaks a rule gets 400 with a JSON error. Other query parameters, such as sort=title, are ignored.
query_limit(target) turns those rules into one value for handle: 0 for no limit, the number for a valid limit, and None for an invalid one. 0 works as “no limit” because a valid limit is never 0.
A real API usually pairs a limit with a way to ask for the next page; those designs are beyond this course. This one stops at the first N.
Milestones
Write
query_limit(target)with the rules above.In
handle, in theGETandHEADbranch for/books, callquery_limit(request["target"]). When it returnsNone, answerjson_error(400, ...). Otherwise build the list as before, by id, ascending, stopping once it holdslimitbooks whenlimitis not 0. TheHEADrule at the end ofhandlestill empties the body, soHEAD /books?limit=1carries the lengthGET /books?limit=1would send.Run
python pl_http.py crudagain. The first reply is nowHTTP/1.0 200 OKwithcontent-length: 73, one book, and the last isHTTP/1.0 400 Bad Request.Run
python -m pytest -q. Your Lesson 6 tests should still pass. You can add tests of your own for?limit=, written the same way, for example:
def test_a_limit_lists_the_first_books():
response = reading_server.handle(make_request("GET", "/books?limit=1"), reading_server.sample_state())
data = json.loads(response["body"].decode("utf-8"))
assert len(data["books"]) == 1
python -m pytest -q
What gets checked
Check my work checks the whole chapter again with books it has not shown you. It calls read_body, query_limit, and handle directly, with good and bad limits, every kind of write, and one bad request per cause, and compares the state before and after each error. It builds your server with make_server without opening a port, passes a POST, a DELETE, and a POST /nowhere with no length through the handler class as bytes, and reads the bytes that come back. It runs serve with a stand-in server, and it runs your tests once against its own correct handle.
Then press Check my work. Your service now keeps its whole contract. Some clients, though, still ask for books at paths from an older version of the API, /v0/books; in Chapter 6 your server tells them where the books went, and your client learns to follow.
Task
Finish the writable reading list: add GET /books?limit=N, and make every row of SERVICE_CONTRACT.md hold.
In reading_server.py:
Write
query_limit(target). Read the query withparse_qs(urlsplit(target).query). Return 0 when there is nolimit. ReturnNonewhenlimitappears more than once, is not made of ASCII digits, or is not from 1 to 100. Otherwise return the limit as an int.In
handle, forGETandHEADon/books: whenquery_limitreturnsNone, answer 400 withjson_error; otherwise list the firstlimitbooks by id, ascending, or every book when the limit is 0. Other query parameters are ignored.
Everything from Lessons 1 to 6 must still work: read_body passed to the adapter by make_server, body_error, the three writes with 201 and location, 200, and 204 without a body or content-length, status by cause with no change to the state on any error, the allow values, and your tests in tests/test_server.py.
In the terminal, python pl_http.py crud runs a full sequence against your server, and python -m pytest -q runs your tests.
Then press Check my work. With books, ids, and limits you have not seen, it calls query_limit, read_body, and handle directly, for valid and invalid limits, HEAD with a limit, every write, and one bad request per cause, comparing the state before and after each error. It builds your server with make_server without opening a port and passes a POST, a DELETE, and a POST /nowhere with no length through the handler class as bytes. It runs serve with a stand-in server. Last, it runs your tests once with its own correct handle in place; they must all pass. Nothing from your terminal is graded.