0%

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:

  • GET and HEAD on /books and /books/{id}; POST /books with 201 and location; PUT /books/{id} with 200; DELETE /books/{id} with 204, no body, and no content-length.

  • 405 with allow: GET, HEAD, POST on the collection and allow: GET, HEAD, PUT, DELETE on 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_body checks the framing before handle runs, so a POST /nowhere with 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:

Try it

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

  1. Write query_limit(target) with the rules above.

  2. In handle, in the GET and HEAD branch for /books, call query_limit(request["target"]). When it returns None, answer json_error(400, ...). Otherwise build the list as before, by id, ascending, stopping once it holds limit books when limit is not 0. The HEAD rule at the end of handle still empties the body, so HEAD /books?limit=1 carries the length GET /books?limit=1 would send.

  3. Run python pl_http.py crud again. The first reply is now HTTP/1.0 200 OK with content-length: 73, one book, and the last is HTTP/1.0 400 Bad Request.

  4. 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:

  1. Write query_limit(target). Read the query with parse_qs(urlsplit(target).query). Return 0 when there is no limit. Return None when limit appears more than once, is not made of ASCII digits, or is not from 1 to 100. Otherwise return the limit as an int.

  2. In handle, for GET and HEAD on /books: when query_limit returns None, answer 400 with json_error; otherwise list the first limit books 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.